From c88939196ef5c79ebf40a681939bd2c07284bdaa Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 26 Oct 2026 12:25:46 +0100 Subject: [PATCH 01/10] fix(release): renew pub credentials and skip example resolution --- .github/actions/publish-package/action.yml | 38 +++++ .../scripts/tests/test_package_publication.py | 143 ++++++++++++++++++ .../package_preparation_contract.yml | 3 + .github/workflows/publish_workflow.yml | 95 ++++++++---- docs/deployment/RUNBOOK.md | 14 +- docs/development/release-process.md | 22 ++- 6 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 .github/actions/publish-package/action.yml create mode 100644 .github/scripts/tests/test_package_publication.py diff --git a/.github/actions/publish-package/action.yml b/.github/actions/publish-package/action.yml new file mode 100644 index 0000000..60c2021 --- /dev/null +++ b/.github/actions/publish-package/action.yml @@ -0,0 +1,38 @@ +name: Publish one immutable package +description: Renew OIDC for each package and resolve only the distributable package. +inputs: + package: + required: true + description: Name of the matched release package. + version: + required: true + description: Accepted stable release version. + dart-version: + required: true + description: Exact Dart SDK version bundled with the tested Flutter SDK. +runs: + using: composite + steps: + # Each package gets a fresh credential after the previous indexing wait. + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ inputs.dart-version }} + - name: Resolve, publish and wait for the exact version + shell: bash + env: + ZSTANDARD_PUBLISH_PACKAGE: ${{ inputs.package }} + ZSTANDARD_PUBLISH_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + case "$ZSTANDARD_PUBLISH_PACKAGE" in + zstandard|zstandard_platform_interface|zstandard_native|zstandard_android|zstandard_ios|zstandard_linux|zstandard_macos|zstandard_web|zstandard_windows|zstandard_cli) ;; + *) echo 'Unknown release package.' >&2; exit 2 ;; + esac + case "$ZSTANDARD_PUBLISH_PACKAGE" in + zstandard_native|zstandard_cli) + (cd "$ZSTANDARD_PUBLISH_PACKAGE" && dart pub get --no-example) ;; + *) + (cd "$ZSTANDARD_PUBLISH_PACKAGE" && flutter pub get --no-example) ;; + esac + dart run scripts/publish_if_missing.dart "$ZSTANDARD_PUBLISH_PACKAGE" "$ZSTANDARD_PUBLISH_VERSION" + dart run scripts/wait_for_pub_version.dart "$ZSTANDARD_PUBLISH_PACKAGE" "$ZSTANDARD_PUBLISH_VERSION" 1200 diff --git a/.github/scripts/tests/test_package_publication.py b/.github/scripts/tests/test_package_publication.py new file mode 100644 index 0000000..5821965 --- /dev/null +++ b/.github/scripts/tests/test_package_publication.py @@ -0,0 +1,143 @@ +"""Run publication shell steps with a clocked registry/SDK fixture, without uploads.""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = '.github/workflows/publish_workflow.yml' +COMPOSITE = '.github/actions/publish-package/action.yml' +ORDER = ['zstandard_platform_interface', 'zstandard_native', 'zstandard_android', + 'zstandard_ios', 'zstandard_linux', 'zstandard_macos', 'zstandard_web', + 'zstandard_windows', 'zstandard_cli', 'zstandard'] + + +class PackagePublicationTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory(prefix='zstd-publisher-test-') + self.addCleanup(self.folder.cleanup) + self.root = Path(self.folder.name) / 'workspace with spaces' + self.root.mkdir() + for package in ORDER: + (self.root / package).mkdir() + self.state = self.root / 'registry.json' + self.state.write_text(json.dumps({'clock': 0, 'issued': None, 'events': []})) + tools = self.root / 'tools' + tools.mkdir() + source = '''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +path = Path(os.environ['REGISTRY_STATE']) +state = json.loads(path.read_text()) +args = sys.argv[1:] +tool = Path(sys.argv[0]).name +package = Path.cwd().name +code = 0 +if tool == 'setup-sdk': + state['issued'] = state['clock'] + state['events'].append(['credential', state['clock']]) +elif args[:2] == ['pub', 'get']: + state['events'].append(['resolve', package, tool]) + if os.environ.get('FAIL_RESOLVE') == package: + code = 43 + elif tool == 'flutter' and package == 'zstandard_web' and '--no-example' not in args: + # Flutter otherwise resolves the Web example's conflicting path/hosted sources. + code = 42 +elif args[:2] == ['run', 'scripts/publish_if_missing.dart']: + package = args[2] + if package in os.environ.get('ALREADY_PUBLISHED', '').split(','): + state['events'].append(['skip', package]) + elif state['issued'] is None or state['clock'] - state['issued'] >= 300: + code = 65 + else: + state['events'].append(['publish', package]) +elif args[:2] == ['run', 'scripts/wait_for_pub_version.dart']: + state['events'].append(['indexed', args[2]]) + state['clock'] += 120 +else: + code = 99 +path.write_text(json.dumps(state)) +sys.exit(code) +''' + for name in ['dart', 'flutter', 'setup-sdk']: + executable = tools / name + executable.write_text(source) + executable.chmod(0o700) + self.env = {'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'HOME': self.folder.name, 'BASH_ENV': os.devnull, + 'REGISTRY_STATE': str(self.state)} + self.action = yaml.safe_load((ROOT / COMPOSITE).read_text()) + workflow = yaml.safe_load((ROOT / WORKFLOW).read_text()) + self.calls = [step for step in workflow['jobs']['publish']['steps'] + if step.get('uses') == './.github/actions/publish-package'] + + def run_package(self, package): + inputs = {'package': package, 'version': '9.8.7', 'dart-version': '3.12.0'} + for step in self.action['runs']['steps']: + if 'uses' in step: + self.assertEqual(step['uses'], 'dart-lang/setup-dart@v1') + self.assertEqual(step['with']['sdk'], '${{ inputs.dart-version }}') + subprocess.run(['setup-sdk'], env=self.env, check=True, timeout=10) + continue + env = dict(self.env) + for key, value in step.get('env', {}).items(): + for name, content in inputs.items(): + value = value.replace('${{ inputs.' + name + ' }}', content) + self.assertNotIn('${{', value) + env[key] = value + result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', step['run']], + cwd=self.root, env=env, capture_output=True, + text=True, timeout=10) + if result.returncode: + return result + return result + + def run_release(self): + for call in self.calls: + result = self.run_package(call['with']['package']) + if result.returncode: + return result + return result + + def events(self, kind): + return [event[1:] for event in json.loads(self.state.read_text())['events'] + if event[0] == kind] + + def test_all_packages_publish_in_order_across_expiring_credentials(self): + self.assertEqual([call['with']['package'] for call in self.calls], ORDER) + result = self.run_release() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.events('publish'), [[package] for package in ORDER]) + self.assertEqual(self.events('indexed'), [[package] for package in ORDER]) + self.assertEqual(self.events('credential'), [[120 * i] for i in range(10)]) + self.assertEqual([event[0] for event in self.events('resolve')], ORDER) + + def test_retry_skips_immutable_versions_and_continues_remaining_packages(self): + self.env['ALREADY_PUBLISHED'] = ','.join(ORDER[:6]) + result = self.run_release() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.events('skip'), [[package] for package in ORDER[:6]]) + self.assertEqual(self.events('publish'), [[package] for package in ORDER[6:]]) + + def test_resolution_failure_stops_before_uploading_or_starting_next_package(self): + self.env['FAIL_RESOLVE'] = 'zstandard_web' + result = self.run_release() + self.assertEqual(result.returncode, 43) + self.assertEqual(self.events('publish'), [[package] for package in ORDER[:6]]) + self.assertEqual([event[0] for event in self.events('resolve')], ORDER[:7]) + + def test_invalid_package_cannot_reach_dependency_resolution(self): + result = self.run_package('../outside') + self.assertEqual(result.returncode, 2) + self.assertEqual(self.events('resolve'), []) + self.assertEqual(self.events('publish'), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/package_preparation_contract.yml b/.github/workflows/package_preparation_contract.yml index 7da85bd..a4e6cd0 100644 --- a/.github/workflows/package_preparation_contract.yml +++ b/.github/workflows/package_preparation_contract.yml @@ -7,6 +7,7 @@ on: - '.github/scripts/check_action_versions.py' - '.github/scripts/update_versions.dart' - '.github/scripts/tests/test_package_preparation.py' + - '.github/scripts/tests/test_package_publication.py' - '.github/scripts/tests/test_hotfix_ref.py' - 'scripts/check_pub_metadata.dart' - 'scripts/check_release_tag.sh' @@ -48,6 +49,8 @@ jobs: run: '"$RUNNER_TEMP/preparation-contract/bin/python" .github/scripts/check_action_versions.py' - name: Verify real metadata preparation and failure behavior run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_package_preparation.py -v' + - name: Verify publication ordering, credential renewal and failure recovery without uploads + run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_package_publication.py -v' - name: Reject preparation outside the matching hotfix branch run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_hotfix_ref.py -v' - name: Verify release and hotfix tag provenance without publication diff --git a/.github/workflows/publish_workflow.yml b/.github/workflows/publish_workflow.yml index e8b2313..cb88e98 100644 --- a/.github/workflows/publish_workflow.yml +++ b/.github/workflows/publish_workflow.yml @@ -105,41 +105,76 @@ jobs: with: ref: ${{ needs.validate_tag.outputs.commit }} fetch-depth: 0 - - # setup-dart provisions the short-lived pub.dev credential from GitHub's - # OIDC token. Flutter is installed afterwards so its bundled Dart stays - # first on PATH while using the same Pub credentials. - - uses: dart-lang/setup-dart@v1 - - uses: ./.github/actions/setup-flutter with: flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Publish in dependency order, safely skipping immutable versions - env: - VERSION: ${{ needs.validate_tag.outputs.version }} + - name: Read the tested Dart SDK version + id: dart run: | set -euo pipefail - publish_and_wait() { - local package="$1" - if [[ "$package" == zstandard_native || "$package" == zstandard_cli ]]; then - (cd "$package" && dart pub get) - else - (cd "$package" && flutter pub get) - fi - dart run scripts/publish_if_missing.dart "$package" "$VERSION" - dart run scripts/wait_for_pub_version.dart "$package" "$VERSION" 1200 - } - - publish_and_wait zstandard_platform_interface - publish_and_wait zstandard_native - for package in \ - zstandard_android zstandard_ios zstandard_linux zstandard_macos \ - zstandard_web zstandard_windows; do - publish_and_wait "$package" - done - publish_and_wait zstandard_cli - publish_and_wait zstandard + version="$(dart --version 2>&1 | sed -nE 's/^Dart SDK version: ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')" + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Publish zstandard_platform_interface + uses: ./.github/actions/publish-package + with: + package: zstandard_platform_interface + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_native + uses: ./.github/actions/publish-package + with: + package: zstandard_native + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_android + uses: ./.github/actions/publish-package + with: + package: zstandard_android + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_ios + uses: ./.github/actions/publish-package + with: + package: zstandard_ios + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_linux + uses: ./.github/actions/publish-package + with: + package: zstandard_linux + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_macos + uses: ./.github/actions/publish-package + with: + package: zstandard_macos + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_web + uses: ./.github/actions/publish-package + with: + package: zstandard_web + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_windows + uses: ./.github/actions/publish-package + with: + package: zstandard_windows + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard_cli + uses: ./.github/actions/publish-package + with: + package: zstandard_cli + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} + - name: Publish zstandard + uses: ./.github/actions/publish-package + with: + package: zstandard + version: ${{ needs.validate_tag.outputs.version }} + dart-version: ${{ steps.dart.outputs.version }} verify: name: Verify all published packages diff --git a/docs/deployment/RUNBOOK.md b/docs/deployment/RUNBOOK.md index 00400e7..a23302d 100644 --- a/docs/deployment/RUNBOOK.md +++ b/docs/deployment/RUNBOOK.md @@ -14,7 +14,7 @@ 5. Run release contract tests, metadata checks and PR platform CI. No tag or registry credential is needed for these checks. 6. Record the release type and SDK compatibility in the Release issue. For a - patch, select `Patch` and `Automatic`; from `v1.5.0`, Copilot resolves `1.5.1`. + patch, select `Patch` and `Automatic`; from `v1.5.1`, Copilot resolves `1.5.2`. Announce Dart 3.12 / Flutter 3.44 minimums in the release notes. Maintenance changes alone do not authorize deployment. @@ -53,7 +53,9 @@ uses GitHub OIDC; no persistent pub token is required. No platform is published during the native build chain. Once all builds and candidate gates pass, the ten package uploads run in dependency order and wait -for indexing. This is not an atomic registry transaction: if publication stops, +for indexing. Each package renews its OIDC credential after the previous wait. +Resolve only the distributable package with `pub get --no-example`; example +apps remain covered by candidate/CI tests. This is not an atomic registry transaction: if publication stops, use Copilot recovery for the same accepted candidate/tag to continue missing versions, rather than rebuilding and uploading platforms independently. @@ -74,6 +76,14 @@ create independent integration PRs or delete managed branches yourself. versions are safely skipped. - Existing tag on recovery: the continuation retries its failed publication workflow, preserving the tag and operation identity. +- Publisher code defect after tagging: a retry runs the same frozen workflow. + Prepare an authorized new patch with the fix, leaving the old tag and public + package versions unchanged. Record the old operation's partial publication; + do not report it as complete. Preserve production fixes in the new candidate + through Copilot's supported branch synchronization before preparation. +- Copilot synchronization verification reports `flutter: command not found`: + provision Flutter for the command's own job, then retry the same supported + command. Do not remove configured tests or manually merge the managed branch. - GitHub Release/checksum finalization failed: rerun the same tag workflow. Existing packages are reused; Copilot verifies its release receipt. - Expired evidence: publication stops. Retention is 90 days; plan a new verified diff --git a/docs/development/release-process.md b/docs/development/release-process.md index 5cdea1a..7800237 100644 --- a/docs/development/release-process.md +++ b/docs/development/release-process.md @@ -31,7 +31,7 @@ pub.dev publication steps. Do not create a competing branch or merge flow. For a patch release, use the installed Release form with **Release Type: Patch** and **Release Version: Automatic**. Copilot resolves the next patch -from the latest stable tag; for example, `v1.5.0` advances to `1.5.1`. Record +from the latest stable tag; for example, `v1.5.1` advances to `1.5.2`. Record the resolved version in the issue's control center before starting deployment. The release notes must announce the current minimums: Dart `^3.12.0` and @@ -124,7 +124,13 @@ zstandard_platform_interface → zstandard_native ``` Each unpublished package must pass `dart pub publish --dry-run`, and the -workflow waits for indexing between packages. Only after all ten versions are +workflow waits for indexing between packages. Each package receives a fresh +OIDC credential after the preceding indexing wait; a token issued once for the +whole sequence can expire before later uploads. Dependency resolution uses +`pub get --no-example`, so publication checks the distributable package without +resolving example applications that intentionally use local path dependencies. +The Dart SDK matches the version bundled with the tested Flutter SDK. +Only after all ten versions are independently verified does Copilot create/verify the GitHub Release, receive the checksum attachment and run `published_deployment_action`. Copilot then performs its configured protected reconciliation. The old independent @@ -144,6 +150,18 @@ moving it. The tag workflow can also be rerun directly after inspecting the failure. Published versions are skipped; reconciliation starts only after all ten packages are verified. +Rerunning a tag workflow uses that tag's original workflow code. An environment +or transient registry failure can be corrected and retried, but a defect in the +frozen publisher requires a corrected new release/version. Keep existing tags +and already published versions unchanged, record the old release as partial, +and obtain authorization for the next patch. Before preparing its candidate, +retain accepted production fixes through Copilot's supported synchronization +command in the new release issue, for example +`/copilot sync-branch --from master --no-agent`. The command must pass the +configured verification, with Flutter available in its own job; a separate +platform CI run does not provision that job. Do not synchronize managed remote +branches manually or mark a partial operation as published. + Evidence is retained for 90 days. Missing, expired or mismatched evidence blocks publication and cannot be replaced by an operator assertion. Prepare a new candidate/version when an old frozen operation cannot be verified. From 2ab16a964574b4674a35ce4b246b2ad5d8b7f46a Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 26 Oct 2026 18:32:00 +0100 Subject: [PATCH 02/10] ci: refresh inherited shallow Git metadata for release checkouts --- .../checkout-release-source/action.yml | 26 +++ .../retire-shallow-cache.cjs | 64 +++++++ .github/scripts/check_action_versions.py | 2 +- .../scripts/tests/test_release_checkout.py | 170 ++++++++++++++++++ .../package_preparation_contract.yml | 3 + .github/workflows/publish_workflow.yml | 4 +- .github/workflows/release_workflow.yml | 22 +-- docs/deployment/RUNBOOK.md | 11 +- docs/development/self-hosted-ci.md | 19 ++ 9 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 .github/actions/checkout-release-source/action.yml create mode 100644 .github/actions/checkout-release-source/retire-shallow-cache.cjs create mode 100644 .github/scripts/tests/test_release_checkout.py diff --git a/.github/actions/checkout-release-source/action.yml b/.github/actions/checkout-release-source/action.yml new file mode 100644 index 0000000..300c873 --- /dev/null +++ b/.github/actions/checkout-release-source/action.yml @@ -0,0 +1,26 @@ +name: Checkout complete release source +description: Avoid unshallowing a reused CI checkout before fetching release history. +inputs: + ref: + description: Exact source ref or commit accepted by the calling release job. + default: '' + fetch-depth: + description: History depth; release jobs require complete history. + default: '0' + persist-credentials: + description: Preserve the calling job's existing checkout credential policy. + default: 'true' +runs: + using: composite + steps: + - name: Retire only the inherited shallow CI Git metadata + uses: actions/github-script@v9 + env: + ZSTANDARD_CHECKOUT_BOOTSTRAP: ${{ github.action_path }}/retire-shallow-cache.cjs + with: + script: return require(process.env.ZSTANDARD_CHECKOUT_BOOTSTRAP)({core}); + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: ${{ inputs.fetch-depth }} + persist-credentials: ${{ inputs.persist-credentials }} diff --git a/.github/actions/checkout-release-source/retire-shallow-cache.cjs b/.github/actions/checkout-release-source/retire-shallow-cache.cjs new file mode 100644 index 0000000..2f5e0f7 --- /dev/null +++ b/.github/actions/checkout-release-source/retire-shallow-cache.cjs @@ -0,0 +1,64 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const {execFileSync} = require('node:child_process'); + +module.exports = function retireShallowCache({core, env = process.env}) { + const repository = env.GITHUB_REPOSITORY || ''; + if (env.GITHUB_ACTIONS !== 'true' || !/^[\w.-]+\/[\w.-]+$/.test(repository)) { + throw new Error('Release checkout requires a GitHub Actions repository workspace.'); + } + const workspace = fs.realpathSync.native(env.GITHUB_WORKSPACE); + const temporary = fs.realpathSync.native(env.RUNNER_TEMP); + const name = repository.split('/')[1]; + const expected = path.join(path.dirname(temporary), name, name); + const equal = (a, b) => process.platform === 'win32' + ? a.toLowerCase() === b.toLowerCase() : a === b; + if (!equal(workspace, expected) || fs.lstatSync(env.GITHUB_WORKSPACE).isSymbolicLink()) { + throw new Error('Refusing to alter Git metadata outside the runner-owned repository workspace.'); + } + const metadata = path.join(workspace, '.git'); + let information; + try { information = fs.lstatSync(metadata); } + catch (error) { if (error.code === 'ENOENT') return {retired: false}; throw error; } + if (!information.isDirectory() || information.isSymbolicLink()) { + throw new Error('Refusing a linked Git directory or worktree.'); + } + const git = (...args) => execFileSync('git', ['-C', workspace, ...args], { + env: {...env, GIT_OPTIONAL_LOCKS: '0'}, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + if (git('rev-parse', '--is-shallow-repository') !== 'true') return {retired: false}; + const origin = new URL(git('config', '--get', 'remote.origin.url')); + if (origin.protocol !== 'https:' || origin.hostname !== 'github.com' + || origin.username || origin.password + || origin.pathname.replace(/\.git$/, '').replace(/^\//, '').toLowerCase() !== repository.toLowerCase()) { + throw new Error('The shallow cache does not belong to the calling GitHub repository.'); + } + for (const name of ['worktrees', 'modules']) { + const directory = path.join(metadata, name); + if (fs.existsSync(directory) && fs.readdirSync(directory).length) { + throw new Error('Retaining Git metadata with registered worktrees or submodules.'); + } + } + for (const name of ['index.lock', 'shallow.lock', 'HEAD.lock']) { + if (fs.existsSync(path.join(metadata, name))) throw new Error('Retaining locked Git metadata.'); + } + try { git('diff', '--quiet'); git('diff', '--cached', '--quiet'); } + catch { throw new Error('Could not verify clean tracked source; retaining the CI Git metadata.'); } + if (git('ls-files', '--others', '--exclude-standard') || git('for-each-ref', '--format=%(objectname)', 'refs/stash')) { + throw new Error('Retaining unexpected untracked files or stashed work in the CI workspace.'); + } + const publicRefs = new Set(git('for-each-ref', '--format=%(objectname)', 'refs/remotes', 'refs/tags').split('\n')); + const branches = git('for-each-ref', '--format=%(objectname)', 'refs/heads').split('\n').filter(Boolean); + if (branches.some(oid => !publicRefs.has(oid))) { + throw new Error('Retaining a local branch that is not represented by the cached remote refs.'); + } + const backup = fs.mkdtempSync(path.join(temporary, 'zstandard-shallow-git-')); + fs.chmodSync(backup, 0o700); + fs.writeFileSync(path.join(backup, '.owner'), JSON.stringify({repository, run: env.GITHUB_RUN_ID, + attempt: env.GITHUB_RUN_ATTEMPT, head: git('rev-parse', 'HEAD')}), {mode: 0o600}); + fs.renameSync(metadata, path.join(backup, 'git')); + core.notice('Retired shallow CI Git metadata to private job temporary storage; checkout will fetch complete history.'); + return {retired: true}; +}; diff --git a/.github/scripts/check_action_versions.py b/.github/scripts/check_action_versions.py index 192ab22..0afb450 100644 --- a/.github/scripts/check_action_versions.py +++ b/.github/scripts/check_action_versions.py @@ -29,7 +29,7 @@ def main(root): for path in paths: for reference in references(yaml.safe_load(path.read_text(encoding="utf-8"))): count += 1 - if isinstance(reference, str) and reference.startswith("./"): + if isinstance(reference, str) and reference.startswith(("./", "$/.github/actions/")): continue if not isinstance(reference, str) or "@" not in reference or not VERSION.fullmatch(reference.rsplit("@", 1)[-1]): errors.append(f"{path.relative_to(root)}: {reference}: use a version tag such as @v1") diff --git a/.github/scripts/tests/test_release_checkout.py b/.github/scripts/tests/test_release_checkout.py new file mode 100644 index 0000000..aea99e3 --- /dev/null +++ b/.github/scripts/tests/test_release_checkout.py @@ -0,0 +1,170 @@ +"""Exercise release cache retirement with real Git repositories, without uploads.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[3] +ACTION = ROOT / '.github/actions/checkout-release-source' +USE = '$/.github/actions/checkout-release-source' +NODE = ''' +try { + const result = require(process.argv[1])({core: {notice() {}}}); + console.log(JSON.stringify(result)); +} catch (error) { console.error(error.message); process.exitCode = 1; } +''' + + +class ReleaseCheckoutTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory(prefix='zstandard checkout tests ') + self.addCleanup(self.folder.cleanup) + self.root = Path(self.folder.name).resolve() + self.environment = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')} + self.environment.update({'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_AUTHOR_NAME': 'Fixture', 'GIT_COMMITTER_NAME': 'Fixture', + 'GIT_AUTHOR_EMAIL': 'fixture@example.invalid', + 'GIT_COMMITTER_EMAIL': 'fixture@example.invalid'}) + self.source = self.root / 'source' + self.source.mkdir() + self.git(self.source, 'init', '-q', '-b', 'main') + for text in ['first', 'second']: + (self.source / 'file.txt').write_text(text) + self.git(self.source, 'add', 'file.txt') + self.git(self.source, 'commit', '-qm', text) + self.case_number = 0 + + def git(self, directory, *arguments): + return subprocess.run(['git', '-C', str(directory), *arguments], check=True, + capture_output=True, text=True, env=self.environment).stdout.strip() + + def case(self, shallow=True): + self.case_number += 1 + work = self.root / f'runner {self.case_number}' / 'work' + temporary = work / '_temp' + temporary.mkdir(parents=True) + workspace = work / 'zstandard/zstandard' + workspace.parent.mkdir() + command = ['clone', '-q'] + (['--depth=1'] if shallow else []) + self.git(self.root, *command, self.source.as_uri(), str(workspace)) + self.git(workspace, 'remote', 'set-url', 'origin', 'https://github.com/vypdev/zstandard') + environment = {**self.environment, 'GITHUB_ACTIONS': 'true', + 'GITHUB_REPOSITORY': 'vypdev/zstandard', 'GITHUB_RUN_ID': '123', + 'GITHUB_RUN_ATTEMPT': '1', 'GITHUB_WORKSPACE': str(workspace), + 'RUNNER_TEMP': str(temporary)} + return workspace, temporary, environment + + def retire(self, environment): + return subprocess.run(['node', '-e', NODE, str(ACTION / 'retire-shallow-cache.cjs')], + env=environment, capture_output=True, text=True, timeout=20) + + def test_shallow_cache_is_preserved_and_a_fresh_fetch_has_complete_history(self): + workspace, temporary, environment = self.case() + original_head = self.git(workspace, 'rev-parse', 'HEAD') + self.assertEqual(self.git(workspace, 'rev-list', '--count', 'HEAD'), '1') + result = self.retire(environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(json.loads(result.stdout)['retired']) + self.assertFalse((workspace / '.git').exists()) + self.assertEqual((workspace / 'file.txt').read_text(), 'second') + retired = next(temporary.glob('zstandard-shallow-git-*')) + self.assertEqual((retired / 'git/HEAD').read_text().strip().split()[-1], 'refs/heads/main') + self.assertEqual(json.loads((retired / '.owner').read_text())['head'], original_head) + self.assertTrue((retired / 'git/shallow').is_file()) + # The next official checkout creates a new store, retaining full ancestry. + self.git(workspace, 'init', '-q') + self.git(workspace, 'remote', 'add', 'origin', self.source.as_uri()) + self.git(workspace, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*') + self.git(workspace, 'checkout', '-qf', '-B', 'main', 'origin/main') + self.assertEqual(self.git(workspace, 'rev-parse', '--is-shallow-repository'), 'false') + self.assertEqual(self.git(workspace, 'rev-list', '--count', 'HEAD'), '2') + self.git(workspace, 'fsck', '--connectivity-only', '--no-dangling') + + def test_complete_or_new_checkout_does_not_retire_metadata(self): + workspace, temporary, environment = self.case(shallow=False) + inode = (workspace / '.git').stat().st_ino + result = self.retire(environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(json.loads(result.stdout)['retired']) + self.assertEqual((workspace / '.git').stat().st_ino, inode) + self.assertEqual(list(temporary.iterdir()), []) + (workspace / '.git').rename(self.root / 'complete-fixture-metadata') + result = self.retire(environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(json.loads(result.stdout)['retired']) + + def test_authored_changes_stashes_and_unpublished_branches_are_retained(self): + for state in ['dirty', 'untracked', 'stash', 'unpublished']: + with self.subTest(state=state): + workspace, temporary, environment = self.case() + if state == 'untracked': + (workspace / 'authored.txt').write_text('retain me') + else: + (workspace / 'file.txt').write_text('retain me') + if state == 'stash': + self.git(workspace, 'stash', 'push', '-q') + if state == 'unpublished': + self.git(workspace, 'add', 'file.txt') + self.git(workspace, 'commit', '-qm', 'unpublished authored work') + inode = (workspace / '.git').stat().st_ino + head = self.git(workspace, 'rev-parse', 'HEAD') + result = self.retire(environment) + self.assertNotEqual(result.returncode, 0) + self.assertEqual((workspace / '.git').stat().st_ino, inode) + self.assertEqual(self.git(workspace, 'rev-parse', 'HEAD'), head) + self.assertEqual(list(temporary.iterdir()), []) + + def test_unrelated_workspace_repository_links_and_locks_are_rejected(self): + for state in ['outside', 'wrong-origin', 'linked', 'lock', 'worktree']: + with self.subTest(state=state): + workspace, temporary, environment = self.case() + if state == 'outside': + environment['RUNNER_TEMP'] = str(self.root) + elif state == 'wrong-origin': + self.git(workspace, 'remote', 'set-url', 'origin', 'https://github.com/other/repository') + elif state == 'linked': + (workspace / '.git').rename(temporary / 'original-git') + (workspace / '.git').symlink_to(temporary / 'original-git', target_is_directory=True) + elif state == 'lock': + (workspace / '.git/index.lock').touch() + elif state == 'worktree': + (workspace / '.git/worktrees/active').mkdir(parents=True) + result = self.retire(environment) + self.assertNotEqual(result.returncode, 0) + self.assertTrue((workspace / '.git').exists()) + self.assertEqual(list(temporary.glob('zstandard-shallow-git-*')), []) + + def test_release_jobs_keep_exact_refs_and_complete_history(self): + action = yaml.safe_load((ACTION / 'action.yml').read_text()) + checkout = action['runs']['steps'][-1] + self.assertEqual(checkout['uses'], 'actions/checkout@v6') + self.assertEqual(action['inputs']['fetch-depth']['default'], '0') + for name, count in [('release_workflow.yml', 11), ('publish_workflow.yml', 2)]: + workflow = yaml.safe_load((ROOT / '.github/workflows' / name).read_text()) + calls = [step for job in workflow['jobs'].values() for step in job.get('steps', []) + if step.get('uses') == USE] + self.assertEqual(len(calls), count) + self.assertTrue(all(str(call['with']['fetch-depth']) == '0' for call in calls)) + workflow = yaml.safe_load((ROOT / '.github/workflows/publish_workflow.yml').read_text()) + self.assertEqual(workflow['jobs']['publish']['steps'][0]['with']['ref'], + '${{ needs.validate_tag.outputs.commit }}') + + def test_checkout_free_local_action_does_not_allow_remote_commit_pins(self): + folder = self.root / 'policy/.github/workflows' + folder.mkdir(parents=True) + path = folder / 'fixture.yml' + command = [sys.executable, str(ROOT / '.github/scripts/check_action_versions.py'), str(folder.parents[1])] + path.write_text(f'jobs:\n fixture:\n steps:\n - uses: {USE}\n - uses: actions/checkout@v6\n') + self.assertEqual(subprocess.run(command, capture_output=True).returncode, 0) + path.write_text('jobs:\n fixture:\n steps:\n - uses: actions/checkout@' + 'a' * 40 + '\n') + self.assertNotEqual(subprocess.run(command, capture_output=True).returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/package_preparation_contract.yml b/.github/workflows/package_preparation_contract.yml index a4e6cd0..bbac11d 100644 --- a/.github/workflows/package_preparation_contract.yml +++ b/.github/workflows/package_preparation_contract.yml @@ -8,6 +8,7 @@ on: - '.github/scripts/update_versions.dart' - '.github/scripts/tests/test_package_preparation.py' - '.github/scripts/tests/test_package_publication.py' + - '.github/scripts/tests/test_release_checkout.py' - '.github/scripts/tests/test_hotfix_ref.py' - 'scripts/check_pub_metadata.dart' - 'scripts/check_release_tag.sh' @@ -47,6 +48,8 @@ jobs: "$RUNNER_TEMP/preparation-contract/bin/python" -m pip install PyYAML==6.0.3 - name: Require version tags for workflow and composite Actions run: '"$RUNNER_TEMP/preparation-contract/bin/python" .github/scripts/check_action_versions.py' + - name: Verify safe replacement of inherited shallow checkout metadata + run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_release_checkout.py -v' - name: Verify real metadata preparation and failure behavior run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_package_preparation.py -v' - name: Verify publication ordering, credential renewal and failure recovery without uploads diff --git a/.github/workflows/publish_workflow.yml b/.github/workflows/publish_workflow.yml index cb88e98..e3acc5d 100644 --- a/.github/workflows/publish_workflow.yml +++ b/.github/workflows/publish_workflow.yml @@ -34,7 +34,7 @@ jobs: issue: ${{ steps.candidate.outputs.issue }} operation-id: ${{ steps.candidate.outputs.operation-id }} steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: fetch-depth: 0 @@ -101,7 +101,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.validate_tag.outputs.commit }} fetch-depth: 0 diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index eb5464d..3aa5692 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -108,7 +108,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ github.ref }} fetch-depth: 0 @@ -197,7 +197,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.update_files.outputs.commit }} fetch-depth: 0 @@ -252,7 +252,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_macos_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -314,7 +314,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_linux_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -383,7 +383,7 @@ jobs: timeout-minutes: 45 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -423,7 +423,7 @@ jobs: timeout-minutes: 120 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -485,7 +485,7 @@ jobs: timeout-minutes: 60 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -526,7 +526,7 @@ jobs: timeout-minutes: 90 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -564,7 +564,7 @@ jobs: timeout-minutes: 75 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -640,7 +640,7 @@ jobs: platform: [ios, macos] dependency_manager: [swiftpm, cocoapods] steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -752,7 +752,7 @@ jobs: - candidate_windows - candidate_apple steps: - - uses: actions/checkout@v6 + - uses: $/.github/actions/checkout-release-source with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 diff --git a/docs/deployment/RUNBOOK.md b/docs/deployment/RUNBOOK.md index a23302d..a2c769d 100644 --- a/docs/deployment/RUNBOOK.md +++ b/docs/deployment/RUNBOOK.md @@ -65,8 +65,15 @@ create independent integration PRs or delete managed branches yourself. ## Recovery -- Preparation failure before promotion: fix the linked branch and rerun. - Native commit steps accept unchanged rebuilt outputs. +- Preparation failure before promotion: fix the linked branch and start a new + preparation through Copilot's authorized launcher when workflow code changed. + GitHub reruns retain the original workflow revision; they suit transient + failures with unchanged code. Native commit steps accept unchanged outputs. +- Checkout reports `pack has unresolved deltas`: deployment checkouts retire + inherited shallow Git metadata in the verified runner workspace before a fresh + full-history fetch. Inspect a refusal rather than deleting worktrees, stashes, + authored changes or another checkout. See + [complete-history checkout](../development/self-hosted-ci.md#complete-history-for-deployment-checkouts). - Remote tip moved: preparation stops rather than silently testing a newer commit. Inspect the concurrent change and start a consistent candidate attempt. - Production content differs: reconcile the production change before preparing diff --git a/docs/development/self-hosted-ci.md b/docs/development/self-hosted-ci.md index f230a58..c2d1e3e 100644 --- a/docs/development/self-hosted-ci.md +++ b/docs/development/self-hosted-ci.md @@ -117,3 +117,22 @@ These CI builds produce disposable validation hosts. Publishable WebAssembly and precompiled CLI libraries are regenerated by the Copilot deployment workflow and retained in its tested candidate. See [artifact storage and handoff](release-process.md#artifact-storage-and-handoff). + +## Complete history for deployment checkouts + +Release preparation and tag validation use the project-owned +`checkout-release-source` composite. GitHub's [checkout-free local Action +syntax](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions) +(`$/`) loads it from the workflow's exact revision before the workspace checkout. +External Actions still use version tags. + +An inherited shallow checkout can fail a full-history fetch with +`pack has unresolved deltas`, even after checkout's built-in retries. Before +fetching release history, the composite verifies the runner workspace hierarchy, +repository identity and clean source state. It moves only shallow Git metadata +to private job temporary storage, retaining the old cache for that job, and lets +the official checkout action fetch a new complete repository. It leaves complete +checkouts alone and refuses linked worktrees, locks, stashes, unexpected files +or local branches absent from cached remote refs. It does not modify the runner's +global Git configuration or any remote branch or tag. The runner's normal job +temporary-directory cleanup retires the cached metadata. From c111678ffc689a5862425cc32ccb6685d5fd2730 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 26 Oct 2026 20:46:03 +0100 Subject: [PATCH 03/10] gh-action: Prepare release 1.5.2 --- CHANGELOG.md | 10 ++++++++++ zstandard/CHANGELOG.md | 10 ++++++++++ zstandard/pubspec.yaml | 16 ++++++++-------- zstandard_android/CHANGELOG.md | 10 ++++++++++ zstandard_android/pubspec.yaml | 6 +++--- zstandard_cli/CHANGELOG.md | 10 ++++++++++ zstandard_cli/lib/src/cli_runner.dart | 2 +- zstandard_cli/pubspec.yaml | 4 ++-- zstandard_ios/CHANGELOG.md | 10 ++++++++++ zstandard_ios/ios/zstandard_ios.podspec | 2 +- zstandard_ios/ios/zstandard_ios/Package.swift | 2 +- zstandard_ios/pubspec.yaml | 6 +++--- zstandard_linux/CHANGELOG.md | 10 ++++++++++ zstandard_linux/pubspec.yaml | 6 +++--- zstandard_macos/CHANGELOG.md | 10 ++++++++++ zstandard_macos/macos/zstandard_macos.podspec | 2 +- .../macos/zstandard_macos/Package.swift | 2 +- zstandard_macos/pubspec.yaml | 6 +++--- zstandard_native/CHANGELOG.md | 10 ++++++++++ zstandard_native/pubspec.yaml | 2 +- zstandard_platform_interface/CHANGELOG.md | 10 ++++++++++ zstandard_platform_interface/pubspec.yaml | 2 +- zstandard_web/CHANGELOG.md | 10 ++++++++++ zstandard_web/pubspec.yaml | 4 ++-- zstandard_windows/CHANGELOG.md | 10 ++++++++++ zstandard_windows/pubspec.yaml | 6 +++--- 26 files changed, 144 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2518391..9a3f665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard/CHANGELOG.md b/zstandard/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard/CHANGELOG.md +++ b/zstandard/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard/pubspec.yaml b/zstandard/pubspec.yaml index 7f0d0ed..e3864f5 100644 --- a/zstandard/pubspec.yaml +++ b/zstandard/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard description: "Flutter plugin for fast Zstandard compression and decompression across native and WebAssembly platforms." -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard @@ -28,13 +28,13 @@ flutter: dependencies: flutter: sdk: flutter - zstandard_android: ^1.5.1 - zstandard_ios: ^1.5.1 - zstandard_linux: ^1.5.1 - zstandard_macos: ^1.5.1 - zstandard_platform_interface: ^1.5.1 - zstandard_web: ^1.5.1 - zstandard_windows: ^1.5.1 + zstandard_android: ^1.5.2 + zstandard_ios: ^1.5.2 + zstandard_linux: ^1.5.2 + zstandard_macos: ^1.5.2 + zstandard_platform_interface: ^1.5.2 + zstandard_web: ^1.5.2 + zstandard_windows: ^1.5.2 dev_dependencies: flutter_lints: ^6.0.0 diff --git a/zstandard_android/CHANGELOG.md b/zstandard_android/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_android/CHANGELOG.md +++ b/zstandard_android/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_android/pubspec.yaml b/zstandard_android/pubspec.yaml index 80fe6b3..2e6c651 100644 --- a/zstandard_android/pubspec.yaml +++ b/zstandard_android/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_android description: Android implementation of the Zstandard compression plugin for Flutter applications. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_android @@ -12,8 +12,8 @@ environment: dependencies: flutter: sdk: flutter - zstandard_platform_interface: ^1.5.1 - zstandard_native: ^1.5.1 + zstandard_platform_interface: ^1.5.2 + zstandard_native: ^1.5.2 ffi: ^2.2.0 dev_dependencies: diff --git a/zstandard_cli/CHANGELOG.md b/zstandard_cli/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_cli/CHANGELOG.md +++ b/zstandard_cli/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_cli/lib/src/cli_runner.dart b/zstandard_cli/lib/src/cli_runner.dart index f0e5852..1c29a78 100644 --- a/zstandard_cli/lib/src/cli_runner.dart +++ b/zstandard_cli/lib/src/cli_runner.dart @@ -8,7 +8,7 @@ import 'utils/constants.dart'; import 'zstandard_cli_base.dart'; /// Version of the command-line interface. -const String zstandardCliVersion = '1.5.1'; +const String zstandardCliVersion = '1.5.2'; /// Runs the compression command and returns a process exit code. Future runCompressCommand( diff --git a/zstandard_cli/pubspec.yaml b/zstandard_cli/pubspec.yaml index 259c35c..0907702 100644 --- a/zstandard_cli/pubspec.yaml +++ b/zstandard_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_cli description: Command-line interface for compressing and decompressing files with Zstandard. -version: 1.5.1 +version: 1.5.2 executables: zstandard-compress: compress @@ -16,7 +16,7 @@ dependencies: ffi: ^2.2.0 path: ^1.9.1 platform: ^3.1.6 - zstandard_native: ^1.5.1 + zstandard_native: ^1.5.2 dev_dependencies: coverage: ^1.15.0 diff --git a/zstandard_ios/CHANGELOG.md b/zstandard_ios/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_ios/CHANGELOG.md +++ b/zstandard_ios/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_ios/ios/zstandard_ios.podspec b/zstandard_ios/ios/zstandard_ios.podspec index 95ebc5c..a958089 100644 --- a/zstandard_ios/ios/zstandard_ios.podspec +++ b/zstandard_ios/ios/zstandard_ios.podspec @@ -5,7 +5,7 @@ Pod::Spec.new do |s| s.cocoapods_version = '>= 1.11.0' # for script_phase :before_headers s.name = 'zstandard_ios' - s.version = '1.5.1' + s.version = '1.5.2' s.summary = 'iOS implementation of the Zstandard Flutter plugin.' s.description = <<-DESC Native Zstandard compression and bounded decompression for Flutter on iOS. diff --git a/zstandard_ios/ios/zstandard_ios/Package.swift b/zstandard_ios/ios/zstandard_ios/Package.swift index 051f262..389a4fe 100644 --- a/zstandard_ios/ios/zstandard_ios/Package.swift +++ b/zstandard_ios/ios/zstandard_ios/Package.swift @@ -10,7 +10,7 @@ let nativePackageDependency: Package.Dependency = { // Match the immutable repository tag carrying this plugin release. return .package( url: "https://github.com/vypdev/zstandard.git", - exact: "1.5.1" + exact: "1.5.2" ) }() diff --git a/zstandard_ios/pubspec.yaml b/zstandard_ios/pubspec.yaml index 790ccd0..e5f7a8f 100644 --- a/zstandard_ios/pubspec.yaml +++ b/zstandard_ios/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_ios description: iOS implementation of the Zstandard compression plugin for Flutter applications. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_ios @@ -12,8 +12,8 @@ environment: dependencies: flutter: sdk: flutter - zstandard_platform_interface: ^1.5.1 - zstandard_native: ^1.5.1 + zstandard_platform_interface: ^1.5.2 + zstandard_native: ^1.5.2 ffi: ^2.2.0 dev_dependencies: diff --git a/zstandard_linux/CHANGELOG.md b/zstandard_linux/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_linux/CHANGELOG.md +++ b/zstandard_linux/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_linux/pubspec.yaml b/zstandard_linux/pubspec.yaml index 2933809..96a2345 100644 --- a/zstandard_linux/pubspec.yaml +++ b/zstandard_linux/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_linux description: Linux implementation of the Zstandard compression plugin for Flutter applications. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_linux @@ -12,8 +12,8 @@ environment: dependencies: flutter: sdk: flutter - zstandard_platform_interface: ^1.5.1 - zstandard_native: ^1.5.1 + zstandard_platform_interface: ^1.5.2 + zstandard_native: ^1.5.2 ffi: ^2.2.0 dev_dependencies: diff --git a/zstandard_macos/CHANGELOG.md b/zstandard_macos/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_macos/CHANGELOG.md +++ b/zstandard_macos/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_macos/macos/zstandard_macos.podspec b/zstandard_macos/macos/zstandard_macos.podspec index 426c29b..eef7c5c 100644 --- a/zstandard_macos/macos/zstandard_macos.podspec +++ b/zstandard_macos/macos/zstandard_macos.podspec @@ -5,7 +5,7 @@ Pod::Spec.new do |s| s.cocoapods_version = '>= 1.11.0' # for script_phase :before_headers s.name = 'zstandard_macos' - s.version = '1.5.1' + s.version = '1.5.2' s.summary = 'macOS implementation of the Zstandard Flutter plugin.' s.description = <<-DESC Native Zstandard compression and bounded decompression for Flutter on macOS. diff --git a/zstandard_macos/macos/zstandard_macos/Package.swift b/zstandard_macos/macos/zstandard_macos/Package.swift index 0746ff3..45f0b5e 100644 --- a/zstandard_macos/macos/zstandard_macos/Package.swift +++ b/zstandard_macos/macos/zstandard_macos/Package.swift @@ -10,7 +10,7 @@ let nativePackageDependency: Package.Dependency = { // Match the immutable repository tag carrying this plugin release. return .package( url: "https://github.com/vypdev/zstandard.git", - exact: "1.5.1" + exact: "1.5.2" ) }() diff --git a/zstandard_macos/pubspec.yaml b/zstandard_macos/pubspec.yaml index b23cacd..e35a6e8 100644 --- a/zstandard_macos/pubspec.yaml +++ b/zstandard_macos/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_macos description: macOS implementation of the Zstandard compression plugin for Flutter applications. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_macos @@ -12,8 +12,8 @@ environment: dependencies: flutter: sdk: flutter - zstandard_platform_interface: ^1.5.1 - zstandard_native: ^1.5.1 + zstandard_platform_interface: ^1.5.2 + zstandard_native: ^1.5.2 ffi: ^2.2.0 dev_dependencies: diff --git a/zstandard_native/CHANGELOG.md b/zstandard_native/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_native/CHANGELOG.md +++ b/zstandard_native/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_native/pubspec.yaml b/zstandard_native/pubspec.yaml index 03be263..1a370d5 100644 --- a/zstandard_native/pubspec.yaml +++ b/zstandard_native/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_native description: Native zstd C sources and FFI bindings for the zstandard Flutter plugin. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_native diff --git a/zstandard_platform_interface/CHANGELOG.md b/zstandard_platform_interface/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_platform_interface/CHANGELOG.md +++ b/zstandard_platform_interface/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_platform_interface/pubspec.yaml b/zstandard_platform_interface/pubspec.yaml index 76c6b90..ff071c3 100644 --- a/zstandard_platform_interface/pubspec.yaml +++ b/zstandard_platform_interface/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_platform_interface description: A common platform interface for the zstandard plugin. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_platform_interface diff --git a/zstandard_web/CHANGELOG.md b/zstandard_web/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_web/CHANGELOG.md +++ b/zstandard_web/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_web/pubspec.yaml b/zstandard_web/pubspec.yaml index 014051a..d567374 100644 --- a/zstandard_web/pubspec.yaml +++ b/zstandard_web/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_web description: Web implementation of the Zstandard compression plugin for Flutter applications. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_web @@ -22,7 +22,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - zstandard_platform_interface: ^1.5.1 + zstandard_platform_interface: ^1.5.2 web: ^1.1.1 dev_dependencies: diff --git a/zstandard_windows/CHANGELOG.md b/zstandard_windows/CHANGELOG.md index 2518391..9a3f665 100644 --- a/zstandard_windows/CHANGELOG.md +++ b/zstandard_windows/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.5.2 - Release recover package publication safely + +- Fix package publication so Flutter resolves only the distributable package, without resolving example apps that mix local and hosted dependencies. +- Renew pub.dev OIDC credentials for each package after the previous package has finished indexing. +- Retain all release-preparation fixes and generated artifacts accepted in production by PR #169; regenerate every distributable artifact in the new deployment preparation phase. +- Document recovery from a partial immutable publication. +- Minimum supported SDKs remain Dart ^3.12.0 and Flutter >=3.44.0; release validation uses Flutter 3.47.2. + +- Make release verification reproducible with job-local Flutter/Dart and native test dependencies, and isolate publication token references from ordinary CI. + ## 1.5.1 - Prepare patch release with complete deployment built artifacts - Fix iOS simulator startup by waiting for its log stream before launching the test application; preserve both SwiftPM and CocoaPods support and workspace/pub-cache native-source resolution. diff --git a/zstandard_windows/pubspec.yaml b/zstandard_windows/pubspec.yaml index af17d07..ca81289 100644 --- a/zstandard_windows/pubspec.yaml +++ b/zstandard_windows/pubspec.yaml @@ -1,6 +1,6 @@ name: zstandard_windows description: Windows implementation of the Zstandard compression plugin for Flutter applications. -version: 1.5.1 +version: 1.5.2 homepage: https://vyp.dev repository: https://github.com/vypdev/zstandard/tree/master/zstandard_windows @@ -12,8 +12,8 @@ environment: dependencies: flutter: sdk: flutter - zstandard_platform_interface: ^1.5.1 - zstandard_native: ^1.5.1 + zstandard_platform_interface: ^1.5.2 + zstandard_native: ^1.5.2 ffi: ^2.2.0 dev_dependencies: From f55bc5bc2d913aa66f188d847b6d726ed29e6060 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 26 Oct 2026 19:49:50 +0000 Subject: [PATCH 04/10] gh-action: Add Windows CLI libraries --- .../lib/src/bin/zstandard_windows_arm64.dll | Bin 563712 -> 563712 bytes .../lib/src/bin/zstandard_windows_x64.dll | Bin 581632 -> 581632 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/zstandard_cli/lib/src/bin/zstandard_windows_arm64.dll b/zstandard_cli/lib/src/bin/zstandard_windows_arm64.dll index 26edc33f2550d8af891e3a99a3920ac2f8ad71be..1f70818ca5ecbe7ee7f2426dc9269b8d6c31aa99 100644 GIT binary patch delta 56 zcmZqJqSUZOX@dkK^ZIr7o241sr5Qn(35c12m<5PgftYQ(G$Xs_GmxU~?$6nGZUO+| C2@$#g delta 56 zcmZqJqSUZOX@dkKvtG}=W@*NDX+{uc0%B$$W&vVWAZFVx&B(6#45Vng`*Zf4n*hEQ B5OM$j diff --git a/zstandard_cli/lib/src/bin/zstandard_windows_x64.dll b/zstandard_cli/lib/src/bin/zstandard_windows_x64.dll index 609cf719aacc04425418fa3fd1d8e637a6543db6..baa1bfd34d9c6169cb6c7af5860121f5fb3c5904 100644 GIT binary patch delta 60 zcmZoTpxkgkc>@O{^W1g!o4Fa=xfwy235c12m<5PgftU@5*|&2uazyHY6mCz~<(Rn% E0Q=MscmMzZ delta 60 zcmZoTpxkgkc>@O{vqI0kW^TrIZblGh0%B$$W&vVWAZ7z%_U+t^9FaO8h1=70Ic9DG E0M|SZH~;_u From 3440f22480f0182dc24f9ca46f60ad22702fe0f7 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 26 Oct 2026 21:58:04 +0100 Subject: [PATCH 05/10] test: preserve Python runtime environment in publisher fixtures --- .github/scripts/tests/test_package_publication.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/scripts/tests/test_package_publication.py b/.github/scripts/tests/test_package_publication.py index 5821965..b9d6dc4 100644 --- a/.github/scripts/tests/test_package_publication.py +++ b/.github/scripts/tests/test_package_publication.py @@ -69,9 +69,13 @@ def setUp(self): executable = tools / name executable.write_text(source) executable.chmod(0o700) - self.env = {'PATH': str(tools) + os.pathsep + os.environ['PATH'], - 'HOME': self.folder.name, 'BASH_ENV': os.devnull, - 'REGISTRY_STATE': str(self.state)} + # setup-python's Linux interpreter needs its shared-library search path + # even when fixture commands run with an otherwise isolated environment. + self.env = {key: value for key, value in os.environ.items() + if key in ['PATH', 'LD_LIBRARY_PATH', 'SYSTEMROOT']} + self.env.update(PATH=str(tools) + os.pathsep + self.env['PATH'], + HOME=self.folder.name, BASH_ENV=os.devnull, + REGISTRY_STATE=str(self.state)) self.action = yaml.safe_load((ROOT / COMPOSITE).read_text()) workflow = yaml.safe_load((ROOT / WORKFLOW).read_text()) self.calls = [step for step in workflow['jobs']['publish']['steps'] From 3de4106503a6eb7c61d468053c0b134726c04e3a Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 26 Oct 2026 22:01:27 +0100 Subject: [PATCH 06/10] ci: keep preparation contract checkouts free of Python bytecode --- .github/workflows/package_preparation_contract.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/package_preparation_contract.yml b/.github/workflows/package_preparation_contract.yml index bbac11d..b5c7395 100644 --- a/.github/workflows/package_preparation_contract.yml +++ b/.github/workflows/package_preparation_contract.yml @@ -29,6 +29,9 @@ jobs: group: vypdev labels: [self-hosted, codex, Linux] timeout-minutes: 10 + env: + # Keep imported test modules out of subsequent clean-workspace checkouts. + PYTHONDONTWRITEBYTECODE: '1' steps: - uses: actions/checkout@v6 with: From c90fc5c866354519a01b38ae7396760cc12c771e Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 26 Oct 2026 22:28:02 +0100 Subject: [PATCH 07/10] ci: restore Apple host migrations after integration tests --- .../preserve-flutter-project-files/action.yml | 13 +- .../tests/test_flutter_project_files.py | 117 ++++++++++++++++++ .../package_preparation_contract.yml | 3 + .github/workflows/pr_check_ios.yml | 10 ++ .github/workflows/pr_check_macos.yml | 10 ++ docs/deployment/RUNBOOK.md | 6 + docs/development/self-hosted-ci.md | 8 ++ 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/tests/test_flutter_project_files.py diff --git a/.github/actions/preserve-flutter-project-files/action.yml b/.github/actions/preserve-flutter-project-files/action.yml index c7c9207..663012e 100644 --- a/.github/actions/preserve-flutter-project-files/action.yml +++ b/.github/actions/preserve-flutter-project-files/action.yml @@ -1,5 +1,5 @@ name: Preserve Flutter project files -description: Preserve files that Flutter may rewrite during dependency resolution. +description: Preserve analysis options and tracked Apple host files that Flutter may rewrite. inputs: package: @@ -12,7 +12,7 @@ inputs: runs: using: composite steps: - - name: Save or restore analysis options + - name: Save or restore Flutter project files shell: bash env: PACKAGE: ${{ inputs.package }} @@ -21,6 +21,14 @@ runs: set -euo pipefail BACKUP_DIR="$RUNNER_TEMP/flutter-project-files/${PACKAGE//\//_}" files=("analysis_options.yaml" "example/analysis_options.yaml") + case "$PACKAGE" in + zstandard_ios|zstandard_macos) + platform="${PACKAGE#zstandard_}" + while IFS= read -r -d '' file; do + files+=("${file#"$PACKAGE/"}") + done < <(git ls-files -z -- "$PACKAGE/example/$platform/Runner.xcodeproj") + ;; + esac if [[ "$MODE" == 'save' ]]; then mkdir -p "$BACKUP_DIR" @@ -33,6 +41,7 @@ runs: elif [[ "$MODE" == 'restore' ]]; then for file in "${files[@]}"; do if [[ -f "$BACKUP_DIR/$file" ]]; then + mkdir -p "$PACKAGE/$(dirname "$file")" cp "$BACKUP_DIR/$file" "$PACKAGE/$file" fi done diff --git a/.github/scripts/tests/test_flutter_project_files.py b/.github/scripts/tests/test_flutter_project_files.py new file mode 100644 index 0000000..57ba3b6 --- /dev/null +++ b/.github/scripts/tests/test_flutter_project_files.py @@ -0,0 +1,117 @@ +"""Run real save/restore steps against tracked Apple host fixtures without Flutter.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[3] +ACTION = './.github/actions/preserve-flutter-project-files' + + +class FlutterProjectFilesTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory(prefix='zstd-project-files-') + self.addCleanup(self.folder.cleanup) + self.root = Path(self.folder.name) / 'workspace with spaces' + self.root.mkdir() + self.temporary = Path(self.folder.name) / 'private state' + self.temporary.mkdir() + self.environment = {key: value for key, value in os.environ.items() + if not key.startswith('GIT_')} + self.environment.update(RUNNER_TEMP=str(self.temporary), BASH_ENV=os.devnull, + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1') + self.git('init', '-q') + action = yaml.safe_load((ROOT / ACTION.removeprefix('./') / 'action.yml').read_text()) + self.script = action['runs']['steps'][0]['run'] + + def git(self, *args): + subprocess.run(['git', *args], cwd=self.root, env=self.environment, + capture_output=True, check=True, timeout=10) + + def write(self, name, content): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + def run_action(self, package, mode): + result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', self.script], + cwd=self.root, env={**self.environment, 'PACKAGE': package, + 'MODE': mode}, capture_output=True, + text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_apple_migrations_restore_exact_starting_files_and_preserve_other_work(self): + for platform in ['ios', 'macos']: + with self.subTest(platform=platform): + package = 'zstandard_' + platform + project = f'{package}/example/{platform}/Runner.xcodeproj' + paths = [f'{package}/analysis_options.yaml', + f'{package}/example/analysis_options.yaml', + project + '/project.pbxproj', + project + '/xcshareddata/xcschemes/Runner.xcscheme', + project + '/xcshareddata/settings with spaces.xml'] + for name in paths: + self.write(name, 'original ' + name) + self.git('add', '--', package) + # Preserve the actual starting worktree, including authored edits, + # rather than replacing it with an assumed HEAD/index baseline. + self.write(paths[2], 'authored baseline before Flutter ran') + originals = {name: (self.root / name).read_bytes() for name in paths} + self.run_action(package, 'save') + for name in paths: + self.write(name, 'Flutter migration changed ' + name) + scheme = self.root / paths[3] + scheme.unlink() + scheme.parent.rmdir() + untracked = self.write(project + '/untracked-note.txt', 'retain untracked work') + unrelated = self.write(package + '/lib/authored.dart', 'retain unrelated work') + self.run_action(package, 'restore') + for name, content in originals.items(): + self.assertEqual((self.root / name).read_bytes(), content) + self.assertEqual(untracked.read_text(), 'retain untracked work') + self.assertEqual(unrelated.read_text(), 'retain unrelated work') + + def test_restore_without_a_snapshot_leaves_source_untouched(self): + path = self.write('zstandard_ios/analysis_options.yaml', 'keep me') + self.git('add', '--', 'zstandard_ios') + self.run_action('zstandard_ios', 'restore') + self.assertEqual(path.read_text(), 'keep me') + self.assertFalse((self.temporary / 'flutter-project-files').exists()) + + def test_non_apple_packages_keep_the_existing_analysis_only_scope(self): + analysis = self.write('zstandard_web/analysis_options.yaml', 'original analysis') + source = self.write('zstandard_web/lib/source.dart', 'original source') + self.git('add', '--', 'zstandard_web') + self.run_action('zstandard_web', 'save') + analysis.write_text('Flutter analysis rewrite') + source.write_text('authored source') + self.run_action('zstandard_web', 'restore') + self.assertEqual(analysis.read_text(), 'original analysis') + self.assertEqual(source.read_text(), 'authored source') + + def test_apple_jobs_restore_after_integration_even_when_a_step_fails(self): + for workflow, job, integration in [ + ('pr_check_ios.yml', 'check_ios_test', 'Run integration tests'), + ('pr_check_macos.yml', 'check_macos_test', + 'Launch macOS example and run integration tests'), + ('release_workflow.yml', 'candidate_apple', 'Run Apple integration tests'), + ]: + with self.subTest(workflow=workflow): + data = yaml.safe_load((ROOT / '.github/workflows' / workflow).read_text()) + steps = data['jobs'][job]['steps'] + integration_index = next(i for i, step in enumerate(steps) + if step.get('name') == integration) + restore_index = max(i for i, step in enumerate(steps) + if step.get('uses') == ACTION + and step['with']['mode'] == 'restore') + self.assertGreater(restore_index, integration_index) + self.assertEqual(steps[restore_index]['if'], 'always()') + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/package_preparation_contract.yml b/.github/workflows/package_preparation_contract.yml index b5c7395..f4df2dc 100644 --- a/.github/workflows/package_preparation_contract.yml +++ b/.github/workflows/package_preparation_contract.yml @@ -9,6 +9,7 @@ on: - '.github/scripts/tests/test_package_preparation.py' - '.github/scripts/tests/test_package_publication.py' - '.github/scripts/tests/test_release_checkout.py' + - '.github/scripts/tests/test_flutter_project_files.py' - '.github/scripts/tests/test_hotfix_ref.py' - 'scripts/check_pub_metadata.dart' - 'scripts/check_release_tag.sh' @@ -53,6 +54,8 @@ jobs: run: '"$RUNNER_TEMP/preparation-contract/bin/python" .github/scripts/check_action_versions.py' - name: Verify safe replacement of inherited shallow checkout metadata run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_release_checkout.py -v' + - name: Verify Apple fixture restoration after Flutter migrations + run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_flutter_project_files.py -v' - name: Verify real metadata preparation and failure behavior run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_package_preparation.py -v' - name: Verify publication ordering, credential renewal and failure recovery without uploads diff --git a/.github/workflows/pr_check_ios.yml b/.github/workflows/pr_check_ios.yml index 7d286bb..8cba6ae 100644 --- a/.github/workflows/pr_check_ios.yml +++ b/.github/workflows/pr_check_ios.yml @@ -45,6 +45,7 @@ jobs: run: flutter pub get - uses: ./.github/actions/preserve-flutter-project-files + if: always() with: package: zstandard_ios mode: restore @@ -112,6 +113,7 @@ jobs: run: flutter pub get - uses: ./.github/actions/preserve-flutter-project-files + if: always() with: package: zstandard_ios mode: restore @@ -215,6 +217,13 @@ jobs: timeout-minutes: 3 run: bash .github/scripts/prepare_ios_flutter.sh --cleanup + - name: Restore Apple host files after Flutter migrations + if: always() + uses: ./.github/actions/preserve-flutter-project-files + with: + package: zstandard_ios + mode: restore + check_ios_publish_dry_run: name: Dry Run Publish iOS if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} @@ -243,6 +252,7 @@ jobs: run: flutter pub get - uses: ./.github/actions/preserve-flutter-project-files + if: always() with: package: zstandard_ios mode: restore diff --git a/.github/workflows/pr_check_macos.yml b/.github/workflows/pr_check_macos.yml index b678df7..6cb2384 100644 --- a/.github/workflows/pr_check_macos.yml +++ b/.github/workflows/pr_check_macos.yml @@ -45,6 +45,7 @@ jobs: run: flutter pub get - uses: ./.github/actions/preserve-flutter-project-files + if: always() with: package: zstandard_macos mode: restore @@ -108,6 +109,7 @@ jobs: run: flutter pub get - uses: ./.github/actions/preserve-flutter-project-files + if: always() with: package: zstandard_macos mode: restore @@ -163,6 +165,13 @@ jobs: test ! -e zstandard_native && test ! -L zstandard_native mv "$ZSTANDARD_NATIVE_ORIGINAL_PATH" zstandard_native + - name: Restore Apple host files after Flutter migrations + if: always() + uses: ./.github/actions/preserve-flutter-project-files + with: + package: zstandard_macos + mode: restore + check_macos_publish_dry_run: name: Dry Run Publish macOS if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} @@ -191,6 +200,7 @@ jobs: run: flutter pub get - uses: ./.github/actions/preserve-flutter-project-files + if: always() with: package: zstandard_macos mode: restore diff --git a/docs/deployment/RUNBOOK.md b/docs/deployment/RUNBOOK.md index a2c769d..96f009a 100644 --- a/docs/deployment/RUNBOOK.md +++ b/docs/deployment/RUNBOOK.md @@ -76,6 +76,12 @@ create independent integration PRs or delete managed branches yourself. [complete-history checkout](../development/self-hosted-ci.md#complete-history-for-deployment-checkouts). - Remote tip moved: preparation stops rather than silently testing a newer commit. Inspect the concurrent change and start a consistent candidate attempt. +- Promotion-PR CI requires a source correction before acceptance: Copilot has + already frozen the candidate SHA. Disable that PR's auto-merge and use an + explicitly approved recovery for the unpublished candidate; the current + limitation is tracked in [Copilot #732](https://github.com/vypdev/copilot/issues/732). + A replacement needs a complete new preparation and its own evidence. Never + reuse the old attestation or rewrite its stored `sourceSha` in place. - Production content differs: reconcile the production change before preparing a new candidate; do not bypass evidence validation. - Publication interrupted or indexing delayed: inspect the issue failure and diff --git a/docs/development/self-hosted-ci.md b/docs/development/self-hosted-ci.md index c2d1e3e..2cd0746 100644 --- a/docs/development/self-hosted-ci.md +++ b/docs/development/self-hosted-ci.md @@ -136,3 +136,11 @@ checkouts alone and refuses linked worktrees, locks, stashes, unexpected files or local branches absent from cached remote refs. It does not modify the runner's global Git configuration or any remote branch or tag. The runner's normal job temporary-directory cleanup retires the cached metadata. + +Apple validation jobs save the tracked Xcode project and scheme files before +Flutter runs and restore their exact starting contents in an `always()` step +after integration tests. Flutter's SwiftPM migrations are test-host changes; +leaving them in a shared checkout can block the next release's clean-source +check. If an older job left those migrations behind, run the corrected Apple +PR checks before preparing again. Their ordinary checkout and final restoration +clean the CI host without weakening the deployment checkout's source guards. From 983fb51c521a5b5e2c0f1b39b5c9d1816006f92b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 26 Oct 2026 21:56:15 +0000 Subject: [PATCH 08/10] gh-action: Add Windows CLI libraries --- .../lib/src/bin/zstandard_windows_arm64.dll | Bin 563712 -> 563712 bytes .../lib/src/bin/zstandard_windows_x64.dll | Bin 581632 -> 581632 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/zstandard_cli/lib/src/bin/zstandard_windows_arm64.dll b/zstandard_cli/lib/src/bin/zstandard_windows_arm64.dll index 1f70818ca5ecbe7ee7f2426dc9269b8d6c31aa99..8311f2dcd1e8cac6208dbee25918a4af2ee849c8 100644 GIT binary patch delta 54 zcmZqJqSUZOX@dkKlmD4!DaLjwMi6EKVrC#_0b*7lX4@{s$gcGasAjv{bM{@E0F-bK AZvX%Q delta 54 zcmZqJqSUZOX@dkK)B1JIQjG0Vj3CSe#LPg<0>rF9%(h*MkzMN@O{ll7TqF2;5)Mi6EKVrC#_0b*7lW&>jO?Oco;Q93|%+tYM8W^Doh Ds-_Mo delta 58 zcmZoTpxkgkc>@O{)7*8rF9%m&2l+qoDyqI7`jwx{WG%-RG1 Dxxx;x From 167b76783bcc05383353c2537accfca482d5840f Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 26 Oct 2026 23:46:33 +0100 Subject: [PATCH 09/10] ci: serialize shared Android emulator sessions across runner services --- .github/workflows/android_sdk_contract.yml | 5 + .github/workflows/pr_check_android.yml | 2 +- docs/development/self-hosted-ci.md | 16 ++ scripts/android_ci_session.py | 94 +++++++++++ scripts/run_android_emulator_ci.sh | 39 +++-- scripts/tests/test_android_ci_session.py | 150 ++++++++++++++++++ .../tests/test_android_sdk_provisioning.py | 77 ++++++++- 7 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 scripts/android_ci_session.py create mode 100644 scripts/tests/test_android_ci_session.py diff --git a/.github/workflows/android_sdk_contract.yml b/.github/workflows/android_sdk_contract.yml index 1ca187e..ee3b7d3 100644 --- a/.github/workflows/android_sdk_contract.yml +++ b/.github/workflows/android_sdk_contract.yml @@ -3,7 +3,9 @@ on: pull_request: paths: - scripts/run_android_emulator_ci.sh + - scripts/android_ci_session.py - scripts/tests/test_android_sdk_provisioning.py + - scripts/tests/test_android_ci_session.py - .github/workflows/android_sdk_contract.yml permissions: contents: read @@ -15,8 +17,11 @@ jobs: group: vypdev labels: [self-hosted, codex, Linux] timeout-minutes: 2 + env: + PYTHONDONTWRITEBYTECODE: '1' steps: - uses: actions/checkout@v6 with: persist-credentials: false - run: python3 scripts/tests/test_android_sdk_provisioning.py + - run: python3 scripts/tests/test_android_ci_session.py diff --git a/.github/workflows/pr_check_android.yml b/.github/workflows/pr_check_android.yml index e8c5dff..cca2e3f 100644 --- a/.github/workflows/pr_check_android.yml +++ b/.github/workflows/pr_check_android.yml @@ -74,7 +74,7 @@ jobs: runs-on: group: vypdev labels: [self-hosted, codex, Linux] - timeout-minutes: 60 + timeout-minutes: 120 strategy: fail-fast: false # The self-hosted runner shares one ADB daemon and one emulator port. diff --git a/docs/development/self-hosted-ci.md b/docs/development/self-hosted-ci.md index 2cd0746..fe12ecd 100644 --- a/docs/development/self-hosted-ci.md +++ b/docs/development/self-hosted-ci.md @@ -118,6 +118,22 @@ and precompiled CLI libraries are regenerated by the Copilot deployment workflow and retained in its tested candidate. See [artifact storage and handoff](release-process.md#artifact-storage-and-handoff). +## Android sessions on shared Linux hosts + +Several runner services on one Linux machine share the Android SDK and ADB +daemon. The Android launcher leases the whole SDK/emulator/test session through +a private, user-scoped host lock, including cleanup. A workflow's matrix limit +alone cannot coordinate another PR or release workflow. Waiting for this lease +is bounded by `ANDROID_CI_LOCK_TIMEOUT_SECONDS` (3600 seconds by default). +Android PR test jobs allow 120 minutes for provisioning, waiting and tests. + +By default, each session uses a job-local AVD home and a unique AVD name. The launcher +exports the selected `ANDROID_SERIAL`, refuses an already active device on its +port and terminates only the emulator process it started. It never kills an +existing device just to make room. The lease stays in a parent process, so an +ADB daemon cannot retain it; cancellation is forwarded to the worker and its +cleanup finishes before the lease is released. + ## Complete history for deployment checkouts Release preparation and tag validation use the project-owned diff --git a/scripts/android_ci_session.py b/scripts/android_ci_session.py new file mode 100644 index 0000000..e66d9df --- /dev/null +++ b/scripts/android_ci_session.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Lease the host's shared Android SDK/ADB/emulator for one complete CI session.""" + +import fcntl +import os +from pathlib import Path +import signal +import stat +import subprocess +import sys +import time + +HANDOFF = 'ZSTANDARD_ANDROID_SESSION_PARENT' + + +def run_session(command, lock_path, wait_seconds=3600): + flags = os.O_CREAT | os.O_RDWR | getattr(os, 'O_NOFOLLOW', 0) + descriptor = os.open(lock_path, flags, 0o600) + with os.fdopen(descriptor, 'a') as lease: + information = os.fstat(lease.fileno()) + if not stat.S_ISREG(information.st_mode) or information.st_uid != os.getuid(): + raise PermissionError('Android CI lease must be an owned regular file.') + deadline = time.monotonic() + wait_seconds + waiting = False + while True: + try: + fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + print('Timed out waiting for the shared Android CI session.', file=sys.stderr) + return 75 + if not waiting: + print('Waiting for the shared Android CI session.', flush=True) + waiting = True + time.sleep(min(0.1, max(0, deadline - time.monotonic()))) + print('Acquired the shared Android CI session.', flush=True) + child = None + pending = [] + + def forward(signum, _frame): + if child is None: + pending.append(signum) + return + try: + os.killpg(child.pid, signum) + except ProcessLookupError: + pass + + signals = [signal.SIGINT, signal.SIGTERM, signal.SIGHUP] + previous = {number: signal.signal(number, forward) for number in signals} + try: + # The lease stays in this parent. SDK daemons must never inherit it. + child = subprocess.Popen(command, start_new_session=True, close_fds=True, + env={**os.environ, HANDOFF: str(os.getpid())}) + for number in pending: + forward(number, None) + result = child.wait() + return result if result >= 0 else 128 - result + finally: + if child is not None and child.poll() is None: + forward(signal.SIGTERM, None) + try: + child.wait(timeout=30) + except subprocess.TimeoutExpired: + forward(signal.SIGKILL, None) + child.wait() + for number, handler in previous.items(): + signal.signal(number, handler) + + +def main(arguments): + if not arguments: + raise ValueError('An Android CI session command is required.') + wait_seconds = int(os.environ.get('ANDROID_CI_LOCK_TIMEOUT_SECONDS', '3600')) + if not 1 <= wait_seconds <= 7200: + raise ValueError('ANDROID_CI_LOCK_TIMEOUT_SECONDS must be between 1 and 7200.') + # Runner services share this user-level host resource, regardless of repo, + # workflow, branch or their separate RUNNER_TEMP directories. + directory = Path('/tmp') / f'zstandard-android-ci-{os.getuid()}' + directory.mkdir(mode=0o700, exist_ok=True) + information = directory.lstat() + if not stat.S_ISDIR(information.st_mode) or information.st_uid != os.getuid() \ + or stat.S_IMODE(information.st_mode) & 0o077: + raise PermissionError('Android CI lease directory must be private and owned.') + return run_session(arguments, directory / 'session.lock', wait_seconds) + + +if __name__ == '__main__': + try: + raise SystemExit(main(sys.argv[1:])) + except (OSError, ValueError) as error: + print(f'Android CI session failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/run_android_emulator_ci.sh b/scripts/run_android_emulator_ci.sh index ee97e84..40d151d 100644 --- a/scripts/run_android_emulator_ci.sh +++ b/scripts/run_android_emulator_ci.sh @@ -10,7 +10,7 @@ fi api_level="${ANDROID_API_LEVEL:-30}" target="${ANDROID_SYSTEM_IMAGE_TARGET:-aosp_atd}" arch="${ANDROID_SYSTEM_IMAGE_ARCH:-x86_64}" -avd_name="${ANDROID_AVD_NAME:-zstandard_ci_test}" +avd_name="${ANDROID_AVD_NAME:-zstandard_ci_test-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-$$}" emulator_port="${EMULATOR_PORT:-5554}" sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" @@ -19,6 +19,15 @@ if [[ -z "$sdk_root" || ! -d "$sdk_root" ]]; then exit 1 fi +# Multiple runner services on one machine share the SDK, ADB daemon and port. +# The Python parent owns the lease until this worker and its cleanup finish. +if [[ "${ZSTANDARD_ANDROID_SESSION_PARENT:-}" != "$PPID" ]]; then + script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + exec python3 "$script_directory/android_ci_session.py" \ + bash "$script_directory/run_android_emulator_ci.sh" "$@" +fi +unset ZSTANDARD_ANDROID_SESSION_PARENT + find_sdk_tool() { local tool_name="$1" local candidate @@ -49,7 +58,7 @@ export ANDROID_SDK_ROOT="$sdk_root" export PATH="$sdk_root/platform-tools:$sdk_root/emulator:$PATH" android_user_home="${ANDROID_USER_HOME:-$HOME/.android}" -avd_home="${ANDROID_AVD_HOME:-$android_user_home/avd}" +avd_home="${ANDROID_AVD_HOME:-${RUNNER_TEMP:-$android_user_home}/zstandard-android-avd}" mkdir -p "$avd_home" export ANDROID_USER_HOME="$android_user_home" export ANDROID_AVD_HOME="$avd_home" @@ -93,13 +102,19 @@ printf 'hw.cpu.ncore=2\nhw.gps=no\n' >> "$avd_config" device="emulator-${emulator_port}" export EMULATOR_PORT="$emulator_port" +export ANDROID_SERIAL="$device" emulator_log="${RUNNER_TEMP:-/tmp}/${avd_name}-${emulator_port}.log" rm -f "$emulator_log" adb_timeout_seconds="${ANDROID_ADB_TIMEOUT_SECONDS:-30}" timeout "$adb_timeout_seconds" "$adb_bin" start-server >/dev/null -timeout "$adb_timeout_seconds" "$adb_bin" -s "$device" emu kill >/dev/null 2>&1 || true +connected_devices="$(timeout "$adb_timeout_seconds" "$adb_bin" devices)" +if printf '%s\n' "$connected_devices" | awk -v serial="$device" \ + '$1 == serial { found = 1 } END { exit !found }'; then + echo "Android device $device is already in use; refusing to replace another session." >&2 + exit 1 +fi echo "Starting software Android emulator on port ${emulator_port}..." "$emulator_bin" \ @@ -116,11 +131,12 @@ echo "Starting software Android emulator on port ${emulator_port}..." -camera-back none \ -accel off > "$emulator_log" 2>&1 & emulator_pid=$! +owned_device=false cleanup() { local exit_code=$? - if (( exit_code != 0 )); then + if (( exit_code != 0 )) && [[ "$owned_device" == true ]]; then local diagnostics_file="${RUNNER_TEMP:-/tmp}/${avd_name}-${emulator_port}-diagnostics.txt" { echo "Android integration diagnostics for ${device}" @@ -150,7 +166,6 @@ cleanup() { tail -n 350 "$diagnostics_file" >&2 || true fi - timeout "$adb_timeout_seconds" "$adb_bin" -s "$device" emu kill >/dev/null 2>&1 || true if kill -0 "$emulator_pid" >/dev/null 2>&1; then kill "$emulator_pid" >/dev/null 2>&1 || true wait "$emulator_pid" >/dev/null 2>&1 || true @@ -169,8 +184,18 @@ echo "Waiting for Android emulator boot..." boot_timeout_seconds=900 boot_started_at="$(date +%s)" while true; do + if ! kill -0 "$emulator_pid" >/dev/null 2>&1; then + echo "Android emulator exited before boot completed." >&2 + exit 1 + fi boot_state="$( timeout "$adb_timeout_seconds" "$adb_bin" -s "$device" shell getprop sys.boot_completed 2>/dev/null || true )" if [[ "$boot_state" == *"1"* ]]; then + registered_avd="$(timeout "$adb_timeout_seconds" "$adb_bin" -s "$device" emu avd name 2>/dev/null | sed -n '1{s/\r$//;p;}' || true)" + if [[ "$registered_avd" != "$avd_name" ]]; then + echo "Android port $emulator_port belongs to a different AVD; refusing to run tests." >&2 + exit 1 + fi + owned_device=true echo "Android emulator boot completed." break fi @@ -179,10 +204,6 @@ while true; do echo "Android emulator did not boot within ${boot_timeout_seconds} seconds." >&2 exit 1 fi - if ! kill -0 "$emulator_pid" >/dev/null 2>&1; then - echo "Android emulator exited before boot completed." >&2 - exit 1 - fi sleep 2 done diff --git a/scripts/tests/test_android_ci_session.py b/scripts/tests/test_android_ci_session.py new file mode 100644 index 0000000..a360fbd --- /dev/null +++ b/scripts/tests/test_android_ci_session.py @@ -0,0 +1,150 @@ +"""Exercise real process locks and cancellation without using a host Android SDK.""" + +import contextlib +import fcntl +import importlib.util +import io +import json +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +sys.dont_write_bytecode = True +MODULE_PATH = Path(__file__).resolve().parents[1] / 'android_ci_session.py' +SPEC = importlib.util.spec_from_file_location('android_ci_session', MODULE_PATH) +SESSION = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SESSION) +ENTRY = ''' +import importlib.util,json,sys +from pathlib import Path +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('session',sys.argv[1]) +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +raise SystemExit(module.run_session(json.loads(sys.argv[4]),Path(sys.argv[2]),float(sys.argv[3]))) +''' + + +class AndroidSessionTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory(prefix='zstd-android-lock-') + self.addCleanup(self.folder.cleanup) + self.root = Path(self.folder.name) + self.lock = self.root / 'shared host lease' + + def stop(self, process): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + process.stdout.close() + process.stderr.close() + + def start(self, command, timeout=5): + process = subprocess.Popen([sys.executable, '-c', ENTRY, str(MODULE_PATH), + str(self.lock), str(timeout), json.dumps(command)], + env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'}, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + self.addCleanup(self.stop, process) + return process + + def wait_for(self, path): + deadline = time.monotonic() + 5 + while not path.exists(): + if time.monotonic() >= deadline: + self.fail('The fixture command did not reach its expected barrier.') + time.sleep(0.01) + + def run_now(self, command, timeout=1): + with contextlib.redirect_stdout(io.StringIO()): + return SESSION.run_session(command, self.lock, timeout) + + def test_concurrent_runner_requests_serialize_the_complete_command(self): + first_entered = self.root / 'first entered' + release = self.root / 'release first' + second_entered = self.root / 'second entered' + first = self.start([sys.executable, '-c', + 'from pathlib import Path;import sys,time;' + 'Path(sys.argv[1]).touch();' + 'exec("while not Path(sys.argv[2]).exists(): time.sleep(0.01)")', + str(first_entered), str(release)]) + self.wait_for(first_entered) + second = self.start([sys.executable, '-c', + 'from pathlib import Path;import sys;Path(sys.argv[1]).touch()', + str(second_entered)]) + self.assertTrue(select.select([second.stdout], [], [], 5)[0]) + self.assertEqual(second.stdout.readline().strip(), + 'Waiting for the shared Android CI session.') + self.assertFalse(second_entered.exists()) + release.touch() + first.communicate(timeout=5) + second.communicate(timeout=5) + self.assertEqual(first.returncode, 0) + self.assertEqual(second.returncode, 0) + self.assertTrue(second_entered.exists()) + + def test_command_failure_and_spawn_error_release_the_lease(self): + self.assertEqual(self.run_now([sys.executable, '-c', 'raise SystemExit(43)']), 43) + with self.assertRaises(FileNotFoundError): + self.run_now([str(self.root / 'missing executable')]) + self.assertEqual(self.run_now([sys.executable, '-c', 'pass']), 0) + + def test_lock_timeout_never_runs_the_waiting_command(self): + entered = self.root / 'must not enter' + with self.lock.open('w') as held: + fcntl.flock(held, fcntl.LOCK_EX) + request = self.start([sys.executable, '-c', + 'from pathlib import Path;import sys;Path(sys.argv[1]).touch()', + str(entered)], timeout=0.2) + _, error = request.communicate(timeout=5) + self.assertEqual(request.returncode, 75, error) + self.assertFalse(entered.exists()) + + def test_cancellation_reaches_the_worker_and_releases_the_lease(self): + entered = self.root / 'worker entered' + request = self.start([sys.executable, '-c', + 'from pathlib import Path;import sys,time;' + 'Path(sys.argv[1]).touch();time.sleep(60)', str(entered)]) + self.wait_for(entered) + request.terminate() + request.communicate(timeout=5) + self.assertEqual(request.returncode, 128 + signal.SIGTERM) + self.assertEqual(self.run_now([sys.executable, '-c', 'pass']), 0) + + def test_sdk_daemons_cannot_retain_the_parent_lease(self): + pid_file = self.root / 'daemon pid' + daemon = ('import subprocess,sys;from pathlib import Path;' + 'p=subprocess.Popen([sys.executable,"-c","import time;time.sleep(60)"],' + 'start_new_session=True,close_fds=False,stdout=subprocess.DEVNULL,' + 'stderr=subprocess.DEVNULL);Path(sys.argv[1]).write_text(str(p.pid))') + request = self.start([sys.executable, '-c', daemon, str(pid_file)]) + request.communicate(timeout=5) + self.assertEqual(request.returncode, 0) + pid = int(pid_file.read_text()) + try: + self.assertEqual(self.run_now([sys.executable, '-c', 'pass'], timeout=0.3), 0) + finally: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def test_symlink_lease_cannot_modify_an_unrelated_file(self): + original = self.root / 'authored source' + original.write_text('retain me') + self.lock.symlink_to(original) + with self.assertRaises(OSError): + self.run_now([sys.executable, '-c', 'raise SystemExit(99)']) + self.assertEqual(original.read_text(), 'retain me') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_android_sdk_provisioning.py b/scripts/tests/test_android_sdk_provisioning.py index 9b368b7..b881801 100644 --- a/scripts/tests/test_android_sdk_provisioning.py +++ b/scripts/tests/test_android_sdk_provisioning.py @@ -2,6 +2,7 @@ import os from pathlib import Path import subprocess +import sys import tempfile import unittest @@ -9,12 +10,15 @@ class AndroidProvisioningTests(unittest.TestCase): - def exercise(self, install_fails): + def exercise(self, install_fails, scenario='avd-fails'): with tempfile.TemporaryDirectory() as folder: root = Path(folder) sdk = root / 'sdk' tools = sdk / 'cmdline-tools/latest/bin' tools.mkdir(parents=True) + timeout_fixture = tools / 'timeout' + timeout_fixture.write_text('#!/bin/bash\nshift\nexec "$@"\n') + timeout_fixture.chmod(0o755) trace = root / 'trace' manager = tools / 'sdkmanager' manager.write_text('''#!/bin/bash @@ -23,19 +27,67 @@ def exercise(self, install_fails): printf 'install\\n' >> "$TRACE" if [[ "$INSTALL_FAILS" == true ]]; then exit 42; fi mkdir -p "$ANDROID_SDK_ROOT/platform-tools" "$ANDROID_SDK_ROOT/emulator" -printf '#!/bin/bash\\nexit 0\\n' > "$ANDROID_SDK_ROOT/platform-tools/adb" -printf '#!/bin/bash\\nexit 0\\n' > "$ANDROID_SDK_ROOT/emulator/emulator" +cp "$ADB_FIXTURE" "$ANDROID_SDK_ROOT/platform-tools/adb" +cp "$EMULATOR_FIXTURE" "$ANDROID_SDK_ROOT/emulator/emulator" chmod +x "$ANDROID_SDK_ROOT/platform-tools/adb" "$ANDROID_SDK_ROOT/emulator/emulator" ''') avd = tools / 'avdmanager' - avd.write_text('#!/bin/bash\nprintf "avd\\n" >> "$TRACE"\nexit 43\n') + avd.write_text('''#!/bin/bash +printf 'avd\\n' >> "$TRACE" +if [[ "$SCENARIO" == avd-fails ]]; then exit 43; fi +while [[ $# -gt 0 ]]; do + if [[ "$1" == --name ]]; then shift; name="$1"; fi + shift +done +mkdir -p "$ANDROID_AVD_HOME/$name.avd" +printf 'fixture\\n' > "$ANDROID_AVD_HOME/$name.avd/config.ini" +''') + adb_fixture = root / 'adb fixture' + adb_fixture.write_text('''#!/bin/bash +printf 'adb:%s\\n' "$*" >> "$TRACE" +if [[ "$1" == devices ]]; then + printf 'List of devices attached\\n' + if [[ "$SCENARIO" == occupied ]]; then printf 'emulator-5554\\toffline\\n'; fi +elif [[ "$*" == *'shell getprop'* ]]; then + while [[ ! -e "$EMULATOR_STARTED" ]]; do sleep 0.01; done + printf '1\\n' +elif [[ "$*" == *'emu avd name'* ]]; then + printf 'foreign_avd\\nOK\\n' +fi +''') + emulator_fixture = root / 'emulator fixture' + emulator_fixture.write_text('''#!/bin/bash +printf 'emulator\\n' >> "$TRACE" +touch "$EMULATOR_STARTED" +trap 'exit 0' TERM +while true; do sleep 0.1; done +''') manager.chmod(0o755) avd.chmod(0o755) env = os.environ.copy() + env.pop('ANDROID_USER_HOME', None) + env.pop('ANDROID_AVD_HOME', None) env.update(HOME=str(root), ANDROID_HOME=str(sdk), ANDROID_SDK_ROOT=str(sdk), + RUNNER_TEMP=str(root / 'runner temp'), PATH=str(tools) + ':/usr/bin:/bin', TRACE=str(trace), + SCENARIO=scenario, ADB_FIXTURE=str(adb_fixture), + EMULATOR_FIXTURE=str(emulator_fixture), + EMULATOR_STARTED=str(root / 'emulator started'), + PYTHONDONTWRITEBYTECODE='1', INSTALL_FAILS=str(install_fails).lower()) - result = subprocess.run(['bash', str(SCRIPT), 'true'], env=env, + # Compose the real coordinator/worker with a private fixture lock; + # tests must never wait on or provision the real host session. + entry = ''' +import importlib.util,sys +from pathlib import Path +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('session',sys.argv[1]) +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +raise SystemExit(module.run_session(['bash',sys.argv[3],'true'],Path(sys.argv[2]),1)) +''' + result = subprocess.run([sys.executable, '-c', entry, + str(SCRIPT.parent / 'android_ci_session.py'), + str(root / 'private lease'), str(SCRIPT)], env=env, text=True, capture_output=True, timeout=10) return result, trace.read_text() if trace.exists() else '' @@ -50,6 +102,21 @@ def test_install_failure_stops_before_avd_or_device_creation(self): self.assertEqual(result.returncode, 42, result.stdout + result.stderr) self.assertEqual(trace, 'install\n') + def test_an_existing_offline_device_is_never_replaced(self): + result, trace = self.exercise(False, scenario='occupied') + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn('already in use', result.stderr) + self.assertNotIn('\nemulator\n', trace) + self.assertNotIn('emu kill', trace) + + def test_a_foreign_booted_avd_cannot_supply_false_readiness(self): + result, trace = self.exercise(False, scenario='foreign') + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn('different AVD', result.stderr) + self.assertIn('\nemulator\n', trace) + self.assertNotIn('emu kill', trace) + self.assertNotIn('dumpsys', trace) + if __name__ == '__main__': unittest.main() From 7f4425b839a4b34dafc36fbf3d9d00bc2a880e07 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 26 Oct 2026 23:11:01 +0000 Subject: [PATCH 10/10] gh-action: Add Windows CLI libraries --- .../lib/src/bin/zstandard_windows_arm64.dll | Bin 563712 -> 563712 bytes .../lib/src/bin/zstandard_windows_x64.dll | Bin 581632 -> 581632 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/zstandard_cli/lib/src/bin/zstandard_windows_arm64.dll b/zstandard_cli/lib/src/bin/zstandard_windows_arm64.dll index 8311f2dcd1e8cac6208dbee25918a4af2ee849c8..6721f54caa987bbb98b3cbebdaa57b6cbef187ab 100644 GIT binary patch delta 54 zcmZqJqSUZOX@dkKQ_|gLDaLjwMi6EKVrC#_0b*7lX4@{s$gcGasAjv{bM{@E0Gpl< Aw*UYD delta 54 zcmZqJqSUZOX@dkKlmD4!DaLjwMi6EKVrC#_0b*7lX4@{s$gcGasAjv{bM{@E0F-bK AZvX%Q diff --git a/zstandard_cli/lib/src/bin/zstandard_windows_x64.dll b/zstandard_cli/lib/src/bin/zstandard_windows_x64.dll index 35e5bd23d1b5cc5ada1655fc1df86cd9278b4fce..c155ab23f6c4fbd895730d59ea6209327f1facb5 100644 GIT binary patch delta 58 zcmZoTpxkgkc>@O{lkMGRF2;5)Mi6EKVrC#_0b*7lW&>jO?Oco;Q93|%+tYM8W^Doh DuB;AD delta 58 zcmZoTpxkgkc>@O{ll7TqF2;5)Mi6EKVrC#_0b*7lW&>jO?Oco;Q93|%+tYM8W^Doh Ds-_Mo