diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 4fe141863..5c8d77f74 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -7,118 +7,196 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to build (e.g., v0.12.0)' + description: Immutable release tag to recover required: true type: string source_ref: - description: 'Source ref to build/publish (defaults to tag; use only for release recovery)' + description: Optional recovery ref; must resolve to the release tag commit required: false type: string + recovery_run_id: + description: Original release workflow run containing the retained approved artifacts + required: true + type: string permissions: {} concurrency: - group: build-binaries-${{ github.event.inputs.tag || github.ref_name }} + group: release-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: false jobs: - publish-npm: + resolve-source: runs-on: ubuntu-latest - environment: npm-publish permissions: contents: read - id-token: write + outputs: + commit: ${{ steps.source.outputs.commit }} + steps: + - name: Checkout release control code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Resolve and validate immutable release source + id: source + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref }} + EXPECTED_COMMIT: ${{ github.event_name == 'push' && github.sha || '' }} + run: node scripts/release-source.mjs + + prepare-artifacts: + needs: resolve-source + runs-on: ubuntu-latest + permissions: + contents: read + actions: read env: + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} - SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} steps: - - name: Checkout + - name: Checkout immutable release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ env.SOURCE_REF }} + ref: ${{ needs.resolve-source.outputs.commit }} persist-credentials: false - - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22' + node-version: '22.19.0' registry-url: 'https://registry.npmjs.org' cache: npm - + - name: Restore original approved artifact bytes + id: restore + env: + GH_TOKEN: ${{ github.token }} + RECOVERY_RUN_ID: ${{ inputs.recovery_run_id }} + run: node scripts/restore-release-artifacts.mjs + - name: Install pinned npm + if: steps.restore.outputs.restored != 'true' + run: npm install -g npm@11.16.0 --ignore-scripts - name: Install system dependencies + if: steps.restore.outputs.restored != 'true' run: | sudo apt-get update sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep - sudo ln -s $(which fdfind) /usr/local/bin/fd - - - name: Install dependencies + sudo ln -s "$(command -v fdfind)" /usr/local/bin/fd + - name: Install locked dependencies + if: steps.restore.outputs.restored != 'true' run: npm ci --ignore-scripts - - - name: Build - run: npm run build - - - name: Check - run: npm run check - - - name: Test - run: npm test - - - name: Upgrade npm for trusted publishing + - name: Freeze model data and build without catalog regeneration + if: steps.restore.outputs.restored != 'true' run: | - npm install -g npm@11.16.0 --ignore-scripts - npm --version - - - name: Publish npm packages - run: node scripts/publish.mjs - - - name: Retain private payload inventory and debug maps - if: always() + npm run hydrate:model-data + node scripts/release-artifacts.mjs freeze .artifacts/release + npm run build:offline + git diff --exit-code HEAD + - name: Validate release candidate + if: steps.restore.outputs.restored != 'true' + run: | + npm run check + ./test.sh + git diff --exit-code HEAD + - name: Prepare reviewed tarballs and private source snapshot + if: steps.restore.outputs.restored != 'true' + run: node scripts/release-artifacts.mjs prepare .artifacts/release + - name: Verify retained input and artifact identities + run: node scripts/release-artifacts.mjs verify .artifacts/release + - name: Retain immutable private release inputs before publication + if: steps.restore.outputs.upload == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: private-release-${{ needs.resolve-source.outputs.commit }} + path: .artifacts/release/ + if-no-files-found: error + include-hidden-files: true + retention-days: 90 + - name: Retain private debug maps + if: always() && steps.restore.outputs.restored != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: private-npm-evidence-${{ env.RELEASE_TAG }} - path: | - .artifacts/npm-payload-inventory.json - .artifacts/private-debug/ + name: private-debug-${{ needs.resolve-source.outputs.commit }} + path: .artifacts/private-debug/ if-no-files-found: warn include-hidden-files: true - retention-days: 14 + retention-days: 90 + + publish-npm: + needs: [resolve-source, prepare-artifacts] + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + actions: read + id-token: write + env: + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} + steps: + - name: Checkout immutable release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-source.outputs.commit }} + persist-credentials: false + - name: Setup Node.js for trusted publishing + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + registry-url: 'https://registry.npmjs.org' + - name: Download approved artifact bytes + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "private-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/release + - name: Install pinned npm for trusted publishing + run: npm install -g npm@11.16.0 --ignore-scripts + - name: Publish or verify exact npm tarballs + run: node scripts/publish.mjs --artifacts .artifacts/release announce-diffex-release: + needs: [resolve-source, publish-npm] runs-on: ubuntu-latest timeout-minutes: 20 - needs: publish-npm environment: diffex-release-upload concurrency: group: announce-diffex-release cancel-in-progress: false permissions: contents: read + actions: read env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} - SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} - AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - AWS_EC2_METADATA_DISABLED: 'true' - R2_ENDPOINT: https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} steps: - - name: Checkout + - name: Checkout immutable release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ env.SOURCE_REF }} + ref: ${{ needs.resolve-source.outputs.commit }} persist-credentials: false - - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22' - + node-version: '22.19.0' + - name: Download approved artifact bytes + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "private-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/release - name: Verify AWS CLI run: aws --version - - name: Announce verified Diffex release + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2_ENDPOINT: https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com run: | node scripts/publish-release-announcement.mjs \ --version "${RELEASE_TAG#v}" \ + --source-commit "$DIFFEX_SOURCE_COMMIT" \ + --artifacts .artifacts/release \ --bucket diffex-artifacts \ --endpoint "$R2_ENDPOINT" diff --git a/RELEASING.md b/RELEASING.md index a4b1be8a7..299d2fa21 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -62,7 +62,7 @@ Use this bootstrap process only once, before the first normal release. Bootstrap publication deliberately omits provenance because it runs interactively outside the trusted CI environment. It does not assign the `latest` dist-tag. - The command is resumable after a partial failure: it skips packages already published at the exact workspace version and refuses packages that already exist only at other versions. + The command is resumable after a partial failure: it downloads and hashes packages already published at the exact workspace version against the retained approved artifacts and refuses packages that already exist only at other versions. 4. Run every `npm trust github` command printed by the bootstrap command. The trusted publisher values must remain: @@ -87,12 +87,15 @@ Run the appropriate release command only after local checks pass and the release ```bash npm run release:patch npm run release:minor -npm run release:major ``` -The release command updates versions and generated artifacts, runs checks and tests, creates the release commits and tag, and pushes `main` plus the tag directly. +The release command updates versions and generated artifacts, runs checks and tests, creates the release commits and tag, and requires interactive review of generated diffs, and pushes `main` plus the tag atomically. +Only `patch` (fixes and additions) and `minor` (breaking changes) are supported. +Major and explicit-version release commands are rejected. It is not a dry-run command. Review the target version and obtain approval for the remote push before running it. The pushed tag triggers `.github/workflows/build-binaries.yml`. Do not announce the release until the workflow verifies npm publication and writes the release announcement successfully. + +See [release promotion and recovery](docs/release/promotion.md) for immutable source selection, retained artifact recovery, and exact failure procedures. diff --git a/docs/release/contract.md b/docs/release/contract.md index 15194b2b6..d7d2eac77 100644 --- a/docs/release/contract.md +++ b/docs/release/contract.md @@ -1,11 +1,15 @@ # Stable npm release contract This contract governs `releases/v1/latest.json`, `releases/v1/releases/.json`, and unauthenticated `GET https://api.diffex.ai/api/latest-version`. -The CLI monorepo publishes records after checking every public workspace package at its exact version and confirming tarball availability. +The CLI monorepo publishes records only after downloading every public workspace package at its exact version and comparing the tarball bytes with the retained approved build manifest. The public API Worker validates the stored record and projects the public response without querying npm per request. The marker means npm-verified stable release availability at publication time. -The release workflow publishes the GitHub Release after the announcement, so the marker alone does not guarantee publicly available binary assets or their upgrade path. -Integrity metadata is validated structurally; this contract does not claim that the publisher downloads tarballs and recomputes their digests. +The approved distribution policy currently enables npm and defers standalone binaries and other installation channels. +The publisher rejects additional enabled channels until their required public asset verification is implemented. +This schema remains an npm contract and does not describe binary assets, signatures, install locks, or a cross-channel release manifest. +The private build manifest binds the release commit, source tree, lockfile, disclosure policy, frozen model data, private source archive, and exact npm tarball sizes, SHA-256 digests, and SHA-512 integrity values. +Registry verification downloads each canonical npm tarball anonymously, rejects redirects, and recomputes both digests before accepting an existing npm version or advancing release storage. +The stronger publisher prerequisites add no stored or HTTP fields and require no installed-client update. ## Stored record @@ -94,9 +98,10 @@ All temporary publisher fixtures are removed after testing, and these checks nee ## Integration status -Record validation and response projection are defined and tested locally. -The existing Worker shell continues returning unavailable until R2 reads and lookup outcome mapping are connected. -The live CLI checker still uses its previous authority and parsing behavior; the strict consumer parser is available for the separately planned endpoint migration. -Its existing update-note behavior has not been changed by defining a response with no note field. -There is no deployed R2-to-HTTP-to-CLI E2E path in this change, and fixture tests do not claim deployed routing or live installation readiness. -The landing application and authenticated Render `/v1` contracts require no change for this contract definition. +The public Worker serves the fixed R2 latest record, and the CLI consumes the exact canonical version endpoint with a pinned npm installation target. +The cloud production and staging operations records retain their deployment and route evidence. +This publisher hardening introduces no Worker deployment or live release publication. +Command-level publication regressions use controlled npm bytes and intercepted storage operations. +The unchanged cloud contract fixtures and Worker tests verify consumer compatibility. +Live publication against actual approved packages remains gated by the separate release authorization and packaging prerequisites. +The landing application and authenticated Render `/v1` contracts require no change. diff --git a/docs/release/promotion.md b/docs/release/promotion.md new file mode 100644 index 000000000..83d7434ce --- /dev/null +++ b/docs/release/promotion.md @@ -0,0 +1,147 @@ +# Release promotion and recovery + +This implementation builds on CLI PR #209 and cloud PR #150. +The approved policy enables the eleven npm packages and defers standalone binaries, Python, secondary installers, and runtime images. +No binary or GitHub Release is created, and the private source repository stays private. +Enabling another channel fails closed until required asset enumeration, immutable publication, and anonymous byte verification exist for that channel. +The R2 schema remains the [npm release contract](contract.md), without a binary manifest or new client fields. + +## Source selection and review + +Only `patch` and `minor` releases are supported. +Patch covers fixes and additions; minor covers breaking changes. +The legacy major release alias and explicit-version invocation reject before mutation. +`scripts/release.mjs` validates one canonical fetch and push origin, a clean `main`, all public package versions, and the stable target version. +It fetches `origin/main`, requires exact `HEAD` equality, and checks local and remote target-tag absence before npm queries or source mutation. +Catalog source updates must already be reviewed and merged; release preparation hydrates model data without regenerating committed catalog source. +Only workspace manifests, root lockfiles, generated CLI shrinkwrap/install locks, and project changelogs may be staged. +Unexpected generated outputs stop the release. +The command displays diffs and requires the operator to inspect all generated changes and type the version in an interactive terminal before creating release refs. +Remote main is refreshed again after review. +The tagged release commit and next-cycle changelog commit are pushed together with `git push --atomic`. + +The CI source resolver fetches the named stable tag, resolves its commit once, verifies the push event commit when present, and checks that the release commit belongs to `origin/main`. +Every public package must match the tag version. +The private monorepo manifest has independent version metadata and does not define the public release version. +A supplied recovery ref must resolve to exactly the same commit, even when the workflow itself runs from another allowed ref. +Every downstream checkout uses the resolved full commit, including publication and announcement. +Recovery can fix workflow orchestration through a reviewed workflow ref, but it cannot substitute different release code or repack different bytes under the same version. + +## Promotion states + +| State | Required evidence and transition | +| --- | --- | +| Source verified | Canonical repository, immutable tag commit, main ancestry, matching versions, and equal recovery source. | +| Inputs frozen | Locked npm dependencies, pinned Node/npm, one hydrated model snapshot, and a private source archive with its SHA-256 digest. | +| Artifacts prepared | Build without catalog regeneration, checks, tests, disclosure inspection of every final tarball, and a manifest binding all input and output digests. | +| Artifacts retained | Successful immutable private Actions artifact upload before npm publishing receives credentials. | +| npm verified | Every existing or newly published package is anonymously downloadable and its size, SHA-256, and SHA-512 match the retained manifest. | +| Version recorded | Conditional creation of the immutable npm record, or exact identity verification of its existing bytes. | +| Announced | Conditional latest-pointer advancement without version regression; equal completion is a no-op. | + +The names describe verified prerequisites, not a mutable state field in the public contract. +The source archive and `inputs.json` freeze inputs before the build. +Packing rejects changes to tracked source or frozen model data, and `manifest.json` must agree with those original inputs. +Registry requests reject redirects and use thirty-second timeouts; tarballs are streamed with a 256 MiB limit. +Missing packages are eligible for publication, while mismatched existing bytes abort before publishing any missing package. +Announcement re-downloads all eleven tarballs and verifies their bytes before either R2 write. +No failed prerequisite advances the pointer. + +## Retained artifacts and retries + +The private Actions artifact is named `private-release-` and contains the approved npm tarballs, `manifest.json`, `inputs.json`, and `source.tar.gz`. +The build manifest records Node/npm versions and the build platform, and binds the private source tree, lockfile, disclosure policy, and model files. +Private debug maps use a separate private artifact. +Both artifacts request ninety-day retention; repository or organization retention limits may shorten that availability. +Archive approved evidence in access-controlled long-term storage before expiry if longer recovery is required. +Neither the source archive nor debug maps are public release assets. + +Rerunning the original workflow downloads its existing artifact and verifies it instead of rebuilding or overwriting it. +A retry before any publication job ran may rebuild if no approved artifact was retained. +After publication has started, missing or expired retained evidence blocks reconstruction. +An operator must restore reviewed original evidence through a separately reviewed recovery path or prepare a new version; no digest mismatch is repaired by overwriting npm or an immutable release. + +For a recovery dispatch, select the original tag and original release run: + +```sh +gh workflow run build-binaries.yml --repo diffexai/diffex --ref main \ + -f tag=vX.Y.Z -f recovery_run_id=ORIGINAL_RUN_ID +``` + +The optional `source_ref` is an equality assertion against the release tag, never an alternative release candidate. +Recovery accepts artifacts only from the canonical repository's release workflow with a push or manual-dispatch event. +It copies the original artifact to the recovery run once; rerunning that recovery reuses the copy. +Ambiguous artifacts, expired evidence, failed downloads, and incomplete publication-job history stop recovery. +Publication consumes `node scripts/publish.mjs --artifacts .artifacts/release` with no rebuild or repack. +The bootstrap helper also retains exact checked bytes under `.artifacts/npm-bootstrap` and verifies existing versions before skipping them. +Keep that directory throughout bootstrap recovery. + +## Git push interruption + +Keep the two prepared commits and the local tag after an ambiguous or failed push. +Inspect both remote refs without changing them: + +```sh +git ls-remote origin refs/heads/main refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}' +node scripts/release.mjs --push-prepared vX.Y.Z +``` + +The recovery command requires clean main, a matching package version, the exact next-cycle commit immediately after the tagged release, no extra next-cycle files, compatible remote main ancestry, and no conflicting remote tag. +It repeats the atomic push without recreating commits or tags. +If the server accepted the original push but its acknowledgement was lost, repetition confirms the same refs without changing identity. +Conflicting remote refs require investigation and are never force-pushed. + +If a server explicitly reports that atomic pushes are unsupported, do not silently fall back. +After reviewing both refs and confirming no conflict, the operator may push `refs/heads/main:refs/heads/main`, fetch and verify that remote main contains the prepared release, and then push `refs/tags/vX.Y.Z:refs/tags/vX.Y.Z` without force. +A failure before the tag push leaves only the prepared branch commits remote and cannot start tag-triggered publication. +A failure after the tag push is resolved by reading the remote tag and workflow run before retrying the identical ref. +GitHub supports the normal atomic path; the command intentionally refuses automatic non-atomic fallback. + +## Publication and announcement failures + +| Failure | Recovery and preservation | +| --- | --- | +| Artifact upload fails | No publication job can start; fix private evidence retention first. | +| Some npm publishes succeed | Keep original artifact; verify every existing version against it and publish only absent versions. | +| Existing tarball differs or is unavailable | Stop; do not overwrite npm, skip verification, or announce. | +| New public channel lacks asset checks | Policy gate blocks promotion before registry or storage operations. | +| Version record succeeds but latest write fails | Re-verify npm and reuse the immutable record's original bytes and timestamp for the pointer retry. | +| Latest is already equal or newer | Preserve it; matching completion is safe to repeat and an older release cannot regress it. | + +Draft cleanup is unrelated to public recovery: the approved npm-only workflow creates no GitHub draft to delete. +Private temporary build directories may be discarded before publication if no retained evidence exists. +After any npm publication, retain evidence and immutable artifacts; never delete a version record to make a conflicting retry pass. +When standalone distribution is eventually enabled, draft cleanup must remain separate from public asset recovery and anonymous asset verification must precede announcement. + +## Validation and limits + +The initial reproduction ran the actual release command against an isolated clean feature branch with an unrelated origin and intercepted npm executable. +It reached npm without validating the branch or origin. +The same command now rejects the origin before npm. +The committed command regressions use temporary Git repositories and bare remotes, controlled registry metadata and tarball bytes, and intercepted npm/AWS/GitHub executables. +They cover dirty source, wrong branch/origin, local and remote divergence, existing tags, version mismatch, divergent recovery refs, partial npm publication, conflicting bytes, failed asset retention, pointer failure, and repeated completion. +Atomic-push tests exercise both a rejected server transaction and an accepted transaction whose acknowledgement is lost, followed by successful recovery using the same commits and tag. +Every fixture directory and local bare remote is removed in cleanup. + +Run from the CLI root: + +```sh +node --test scripts/release-source.test.mjs scripts/restore-release-artifacts.test.mjs scripts/publish-release-announcement.test.mjs scripts/npm-publish-plan.test.mjs scripts/release-contract.test.mjs +node --test scripts/*.test.mjs +npm run check +./test.sh +``` + +Run the cloud root and isolated public Worker `npm run check` gates and compare mirrored contracts with `node scripts/check-release-contract.mjs /path/to/diffex-new`. +Actionlint validates the release workflow. +These checks do not publish a real release, execute GitHub's credentialed publication jobs, or establish real installed-client acceptance. +The owner's changelog regeneration and actual approved-artifact/publication acceptance remain release prerequisites. +Standalone public asset publication failure cannot be exercised while that channel is deferred; the enabled-channel guard prevents pretending its assets were verified. + +Validation completed on 2026-09-12 with Node 22.19.0. +The CLI `npm run check`, complete `./test.sh` non-E2E suite, all script tests, and actionlint 1.7.12 passed. +The focused release commands were rerun after correcting the private-root/public-workspace version distinction and passed against both fixture and actual workspace inventory. +The cloud root gate passed 546 unit and 23 integration tests; the public Worker gate passed 352 tests, both deployment dry runs, and the dependency audit. +The mirrored contract check passed. +All release fixtures used temporary local directories, synthetic artifacts, and intercepted external operations, and cleanup completed. +No credentialed release workflow was dispatched, so there is no publication workflow run or real release manifest to cite for this implementation. diff --git a/docs/release/publication.md b/docs/release/publication.md index 029d21eeb..b4712b78c 100644 --- a/docs/release/publication.md +++ b/docs/release/publication.md @@ -5,9 +5,10 @@ The CLI monorepo owns npm verification and release-record publication through `scripts/publish-release-announcement.mjs` and `.github/workflows/build-binaries.yml`. The independent public API Worker in `diffex-cloud` consumes these records; Worker, Render, and landing deployments do not advance them. -The release workflow builds binaries, stages a draft GitHub Release, publishes npm packages, announces verified npm availability, and finally publishes the GitHub Release. -The R2 marker means that all public workspace packages resolved at their exact release version and their tarballs were available. -It does not independently guarantee that binary assets are already public. +The release workflow verifies one immutable source commit, freezes model data and source inputs, builds the approved npm packages, retains their exact bytes, publishes missing versions, and announces verified npm availability. +The R2 marker means all public workspace packages were downloaded at their exact release version and matched the retained approved artifact digests. +Standalone binaries remain deferred, and the npm record contains no binary manifest. +See [promotion and recovery](promotion.md) for the complete prerequisites, private artifact retention, and partial-failure procedures. This setup does not authorize running a release or bypass the repository's release requirements. ## Credentials and environment boundaries @@ -27,7 +28,7 @@ The repository's GitHub plan rejected required-reviewer protection with HTTP 422 The owner approved branch/tag restrictions without required reviewers on 2026-09-10. GitHub reports `can_admins_bypass: true`; there is no enforced human approval gate. Review release tags and any manually selected recovery source before dispatching a workflow. -These restrictions apply to the workflow ref, so a recovery `source_ref` still needs maintainer review. +These restrictions apply to the workflow ref; the resolver additionally rejects a recovery `source_ref` that differs from the immutable tag commit. Missing credentials fail before npm verification or storage access. Use an AWS CLI that supports `put-object --if-match` and `--if-none-match`. @@ -49,10 +50,10 @@ If enforced read-only storage access is required, configure a separate read-only The [stable release contract](contract.md) specifies the exact stored schema and public HTTP projection. The publisher now enforces that schema for both new and existing records. -The consumer fixture suite prepares strict parsing for the future endpoint migration. +The unchanged consumer fixture suite verifies the deployed npm record shape and public response projection. Before any write, the publisher inventories every non-private workspace package and requires its version to match the requested canonical stable semver. -It checks exact npm package identity and version, integrity metadata, and tarball availability for all packages. +It checks exact npm package identity and version, downloads every canonical tarball without redirects, and compares its size, SHA-256, and SHA-512 with the retained manifest. Requests have a 30-second timeout, npm propagation retries have a ten-minute budget, and storage commands have a one-minute timeout. `releases/v1/releases/.json` is created with `If-None-Match: *`. diff --git a/scripts/fixtures/release-publication/aws.mjs b/scripts/fixtures/release-publication/aws.mjs index 126f4a39e..45982db0c 100644 --- a/scripts/fixtures/release-publication/aws.mjs +++ b/scripts/fixtures/release-publication/aws.mjs @@ -8,7 +8,10 @@ const path = process.env.DIFFEX_PUBLICATION_STORAGE; const state = JSON.parse(readFileSync(path, "utf8")); const key = value("--key"); const object = state.objects[key]; -if (state.failure) { +if ( + state.failure && + (state.failure !== "PointerFailure" || (args[1] === "put-object" && key === "releases/v1/latest.json")) +) { console.error(`An error occurred (${state.failure}) when calling the fixture operation: fixture-secret`); process.exit(1); } diff --git a/scripts/fixtures/release-publication/git.mjs b/scripts/fixtures/release-publication/git.mjs new file mode 100644 index 000000000..3d1e086a8 --- /dev/null +++ b/scripts/fixtures/release-publication/git.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +const { commit } = JSON.parse(readFileSync(process.env.DIFFEX_PUBLICATION_FIXTURE, "utf8")); +const args = process.argv.slice(2); +if (args.join(" ") === "rev-parse HEAD") console.log(commit); +else if (args.join(" ") === "rev-parse HEAD^{tree}") console.log("c".repeat(40)); +else throw new Error("Unmocked publication Git request"); diff --git a/scripts/fixtures/release-publication/harness.mjs b/scripts/fixtures/release-publication/harness.mjs index 5b74351cb..b388a964d 100644 --- a/scripts/fixtures/release-publication/harness.mjs +++ b/scripts/fixtures/release-publication/harness.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { integrity, sha256 } from "../../release-registry.mjs"; import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -7,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { getPublicWorkspacePackages } from "../../release-packages.mjs"; const publisher = fileURLToPath(new URL("../../publish-release-announcement.mjs", import.meta.url)); +const npmPublisher = fileURLToPath(new URL("../../publish.mjs", import.meta.url)); const registry = fileURLToPath(new URL("./registry.mjs", import.meta.url)); export function createPublicationFixture({ staging = false } = {}) { const directory = mkdtempSync(join(tmpdir(), "diffex-publication-test-")); @@ -17,6 +19,21 @@ export function createPublicationFixture({ staging = false } = {}) { const aws = join(directory, "bin/aws"); copyFileSync(fileURLToPath(new URL("./aws.mjs", import.meta.url)), aws); chmodSync(aws, 0o700); + for (const command of ["git", "npm"]) { + const file = join(directory, "bin", command); + copyFileSync(fileURLToPath(new URL(`./${command}.mjs`, import.meta.url)), file); + chmodSync(file, 0o700); + } + const policy = JSON.parse( + readFileSync(new URL("../../../docs/release/distribution-policy.json", import.meta.url), "utf8"), + ); + mkdirSync(join(directory, "docs/release"), { recursive: true }); + writeFileSync(join(directory, "docs/release/distribution-policy.json"), JSON.stringify(policy)); + writeFileSync(join(directory, "package-lock.json"), "fixture locked inputs"); + const artifactDirectory = join(directory, "artifacts"); + mkdirSync(artifactDirectory); + writeFileSync(join(artifactDirectory, "source.tar.gz"), "fixture frozen source"); + if (staging) rmSync(aws); const packages = getPublicWorkspacePackages(); const bucket = staging ? "diffex-artifacts-staging" : "fixture-releases"; const endpoint = staging @@ -29,7 +46,24 @@ export function createPublicationFixture({ staging = false } = {}) { packageCount: packages.length, state: () => JSON.parse(readFileSync(statePath, "utf8")), seed: (objects, failure) => writeFileSync(statePath, JSON.stringify({ objects, failure, calls: [] })), - run(version, { failure, credentials = true, commit = "a".repeat(40) } = {}) { + run( + version, + { + failure, + credentials = true, + commit = "a".repeat(40), + command = "announce", + missing = false, + failPublishAt, + mutateManifest, + mutatePolicy, + } = {}, + ) { + writeFileSync(join(directory, "package.json"), JSON.stringify({ version, private: true })); + writeFileSync( + join(directory, "docs/release/distribution-policy.json"), + JSON.stringify(mutatePolicy ? mutatePolicy(structuredClone(policy)) : policy), + ); const manifests = packages.map((pkg, index) => { const target = join(directory, "packages", `package-${index}`); mkdirSync(target, { recursive: true }); @@ -37,9 +71,40 @@ export function createPublicationFixture({ staging = false } = {}) { writeFileSync(join(target, "package.json"), JSON.stringify(manifest)); return manifest; }); - writeFileSync(fixturePath, JSON.stringify({ packages: manifests, failure })); + const artifactPackages = manifests.map((pkg) => { + const bytes = Buffer.from(`approved ${pkg.name}@${pkg.version}`); + const artifact = { + ...pkg, + filename: `${pkg.name.replace("@", "").replace("/", "-")}-${version}.tgz`, + sha256: sha256(bytes), + integrity: integrity(bytes), + size: bytes.length, + }; + writeFileSync(join(artifactDirectory, artifact.filename), bytes); + return artifact; + }); + const manifest = { + schemaVersion: 1, + version, + source: { + repository: "diffexai/diffex", + commit, + tree: "c".repeat(40), + lockSha256: sha256(readFileSync(join(directory, "package-lock.json"))), + policySha256: sha256(readFileSync(join(directory, "docs/release/distribution-policy.json"))), + }, + modelData: [{ path: "fixture.json", sha256: sha256("fixture models") }], + snapshot: { filename: "source.tar.gz", sha256: sha256("fixture frozen source") }, + packages: artifactPackages, + }; + const { schemaVersion, packages: artifacts, ...inputs } = manifest; + writeFileSync(join(artifactDirectory, "inputs.json"), JSON.stringify(inputs)); + if (mutateManifest) mutateManifest(manifest); + writeFileSync(join(artifactDirectory, "manifest.json"), JSON.stringify(manifest)); + writeFileSync(fixturePath, JSON.stringify({ packages: manifests, failure, commit, missing, failPublishAt })); const env = { - PATH: staging ? process.env.PATH : `${join(directory, "bin")}:${process.env.PATH}`, + PATH: `${join(directory, "bin")}:${process.env.PATH}`, + DIFFEX_FIXTURE_REAL_AWS: staging ? "true" : "", AWS_ACCESS_KEY_ID: credentials ? (staging ? process.env.AWS_ACCESS_KEY_ID : "fixture-access") : "", AWS_SECRET_ACCESS_KEY: credentials ? (staging ? process.env.AWS_SECRET_ACCESS_KEY : "fixture-secret") : "", AWS_SESSION_TOKEN: staging ? (process.env.AWS_SESSION_TOKEN ?? "") : "", @@ -54,15 +119,21 @@ export function createPublicationFixture({ staging = false } = {}) { [ "--import", registry, - publisher, - "--version", - version, - "--bucket", - bucket, - "--endpoint", - endpoint, - "--source-commit", - commit, + ...(command === "publish" + ? [npmPublisher, "--artifacts", artifactDirectory] + : [ + publisher, + "--artifacts", + artifactDirectory, + "--version", + version, + "--bucket", + bucket, + "--endpoint", + endpoint, + "--source-commit", + commit, + ]), ], { cwd: directory, env, encoding: "utf8", timeout: staging ? 180000 : 30000 }, ); diff --git a/scripts/fixtures/release-publication/npm.mjs b/scripts/fixtures/release-publication/npm.mjs new file mode 100644 index 000000000..52c594449 --- /dev/null +++ b/scripts/fixtures/release-publication/npm.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { basename } from "node:path"; +const path = process.env.DIFFEX_PUBLICATION_STORAGE; +const state = JSON.parse(readFileSync(path, "utf8")); +const fixture = JSON.parse(readFileSync(process.env.DIFFEX_PUBLICATION_FIXTURE, "utf8")); +const args = process.argv.slice(2); +if (args[0] !== "publish" || !args.includes("--ignore-scripts") || !args.includes("--provenance")) + throw new Error("Unmocked npm request"); +const file = basename(args.at(-1)); +const pkg = fixture.packages.find( + (pkg) => file === `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`, +); +if (!pkg || readFileSync(args.at(-1), "utf8") !== `approved ${pkg.name}@${pkg.version}`) + throw new Error("Publishing unexpected artifact bytes"); +state.published ??= []; +if (state.published.includes(pkg.name)) throw new Error("Attempted to rewrite a published npm version"); +if (fixture.failPublishAt === pkg.name) process.exit(1); +state.published.push(pkg.name); +writeFileSync(path, JSON.stringify(state)); diff --git a/scripts/fixtures/release-publication/registry.mjs b/scripts/fixtures/release-publication/registry.mjs index 0cd7d699b..25f237bd0 100644 --- a/scripts/fixtures/release-publication/registry.mjs +++ b/scripts/fixtures/release-publication/registry.mjs @@ -1,4 +1,5 @@ -import { readFileSync } from "node:fs"; +import { integrity } from "../../release-registry.mjs"; +import { readFileSync, writeFileSync } from "node:fs"; import { syncBuiltinESMExports } from "node:module"; import net from "node:net"; import tls from "node:tls"; @@ -20,22 +21,36 @@ Date.now = () => { }; globalThis.fetch = async (input, options) => { const url = new URL(input); - if (url.origin === "https://registry.npmjs.org") { - const pkg = fixture.packages.find((pkg) => url.pathname === `/${encodeURIComponent(pkg.name)}/${pkg.version}`); - if (!pkg || options?.method === "HEAD") throw new Error("Unexpected registry request"); - if (fixture.failure === "package" && pkg.name === fixture.packages.at(-1).name) + if (options?.redirect !== "error") throw new Error("Registry and tarballs must reject redirects"); + const state = JSON.parse(readFileSync(process.env.DIFFEX_PUBLICATION_STORAGE, "utf8")); + state.registryCalls ??= []; + state.registryCalls.push({ url: url.href, method: options?.method ?? "GET" }); + writeFileSync(process.env.DIFFEX_PUBLICATION_STORAGE, JSON.stringify(state)); + if (url.origin !== "https://registry.npmjs.org") throw new Error("Unexpected publication fixture origin"); + const pkg = fixture.packages.find((pkg) => url.pathname === `/${encodeURIComponent(pkg.name)}/${pkg.version}`); + if (pkg) { + if ( + (fixture.failure === "package" && pkg.name === fixture.packages.at(-1).name) || + (fixture.missing && !state.published?.includes(pkg.name)) + ) return new Response(null, { status: 404 }); return Response.json({ name: pkg.name, version: pkg.version, dist: { - tarball: `https://tarballs.example.invalid/${encodeURIComponent(pkg.name)}/${pkg.version}.tgz`, - integrity: `sha512-${"A".repeat(86)}==`, + tarball: `https://registry.npmjs.org/${pkg.name}/-/${pkg.name.split("/").pop()}-${pkg.version}.tgz`, + integrity: + fixture.failure === "integrity" ? integrity("wrong bytes") : integrity(`approved ${pkg.name}@${pkg.version}`), }, }); } - if (url.origin === "https://tarballs.example.invalid" && options?.method === "HEAD") { - return new Response(null, { status: fixture.failure === "tarball" ? 503 : 200 }); - } - throw new Error("Unexpected publication fixture request"); + const artifact = fixture.packages.find( + (pkg) => url.pathname === `/${pkg.name}/-/${pkg.name.split("/").pop()}-${pkg.version}.tgz`, + ); + if (!artifact || options?.method === "HEAD") + throw new Error("Unexpected tarball request: downloaded bytes are required"); + return new Response( + fixture.failure === "bytes" ? "different public bytes" : `approved ${artifact.name}@${artifact.version}`, + { status: fixture.failure === "tarball" ? 503 : 200 }, + ); }; diff --git a/scripts/npm-publish-plan.mjs b/scripts/npm-publish-plan.mjs index 9948fdc2a..ce15284c0 100644 --- a/scripts/npm-publish-plan.mjs +++ b/scripts/npm-publish-plan.mjs @@ -1,17 +1,21 @@ export const BOOTSTRAP_DIST_TAG = "bootstrap"; -const USAGE = "Usage: node scripts/publish.mjs [--dry-run] [--bootstrap]"; +const USAGE = "Usage: node scripts/publish.mjs [--dry-run] [--bootstrap] [--artifacts DIRECTORY]"; export function parsePublishArgs(args) { - const unknownArgs = args.filter((arg) => arg !== "--dry-run" && arg !== "--bootstrap"); - if (unknownArgs.length > 0) { - throw new Error(USAGE); + const options = { bootstrap: false, dryRun: false }; + const seen = new Set(); + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (seen.has(arg)) throw new Error(USAGE); + seen.add(arg); + if (arg === "--bootstrap") options.bootstrap = true; + else if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--artifacts" && args[index + 1] && !args[index + 1].startsWith("--")) + options.artifacts = args[++index]; + else throw new Error(USAGE); } - - return { - bootstrap: args.includes("--bootstrap"), - dryRun: args.includes("--dry-run"), - }; + return options; } export function createPublishArgs({ bootstrap }) { diff --git a/scripts/npm-publish-plan.test.mjs b/scripts/npm-publish-plan.test.mjs index 299c8b2a9..e60678ff0 100644 --- a/scripts/npm-publish-plan.test.mjs +++ b/scripts/npm-publish-plan.test.mjs @@ -62,3 +62,18 @@ describe("npm publish planning", () => { ]); }); }); + +it("requires exactly one artifact directory and rejects ambiguous options", () => { + assert.deepEqual(parsePublishArgs(["--artifacts", "/tmp/approved", "--dry-run"]), { + bootstrap: false, + dryRun: true, + artifacts: "/tmp/approved", + }); + for (const args of [ + ["--artifacts"], + ["--artifacts", "--bootstrap"], + ["--artifacts", "a", "--artifacts", "b"], + ["--bootstrap", "--bootstrap"], + ]) + assert.throws(() => parsePublishArgs(args)); +}); diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs index 5f9238f51..0e7d3c669 100644 --- a/scripts/publish-release-announcement.mjs +++ b/scripts/publish-release-announcement.mjs @@ -7,18 +7,18 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { isReleaseRecord, isStableVersion } from "../packages/coding-agent/src/utils/release-contract.ts"; -import { getPublicWorkspacePackages } from "./release-packages.mjs"; +import { readReleaseArtifacts } from "./release-artifacts.mjs"; +import { verifyNpmArtifact } from "./release-registry.mjs"; const RELEASES_PREFIX = "releases/v1"; -const REGISTRY_URL = "https://registry.npmjs.org"; const RETRY_DELAY_MS = 5000; const RETRY_TIMEOUT_MS = 10 * 60 * 1000; const MAX_POINTER_UPDATE_ATTEMPTS = 5; const STABLE_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; -const REQUEST_TIMEOUT_MS = 30_000; function parseArgs(args) { const options = { + artifacts: undefined, bucket: undefined, endpoint: undefined, sourceCommit: undefined, @@ -27,18 +27,29 @@ function parseArgs(args) { for (let index = 0; index < args.length; index++) { const arg = args[index]; - if (arg !== "--bucket" && arg !== "--endpoint" && arg !== "--source-commit" && arg !== "--version") { + if ( + arg !== "--artifacts" && + arg !== "--bucket" && + arg !== "--endpoint" && + arg !== "--source-commit" && + arg !== "--version" + ) { throw new Error(`Unknown argument: ${arg}`); } const value = args[++index]; if (!value) throw new Error(`${arg} requires a value`); options[ - { "--bucket": "bucket", "--endpoint": "endpoint", "--source-commit": "sourceCommit", "--version": "version" }[ - arg - ] + { + "--artifacts": "artifacts", + "--bucket": "bucket", + "--endpoint": "endpoint", + "--source-commit": "sourceCommit", + "--version": "version", + }[arg] ] = value; } + if (!options.artifacts) throw new Error("--artifacts is required"); if (!options.bucket) throw new Error("--bucket is required"); if (!options.endpoint) throw new Error("--endpoint is required"); if (!isStableVersion(options.version)) { @@ -51,45 +62,6 @@ function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -async function resolvePackageFromNpm(pkg) { - const packageUrl = `${REGISTRY_URL}/${encodeURIComponent(pkg.name)}/${pkg.version}`; - const response = await fetch(packageUrl, { - headers: { accept: "application/json" }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error(`${pkg.name}@${pkg.version}: npm registry returned ${response.status}`); - } - - const data = await response.json(); - if ( - typeof data !== "object" || - data === null || - Array.isArray(data) || - data.name !== pkg.name || - data.version !== pkg.version || - typeof data.dist !== "object" || - data.dist === null || - Array.isArray(data.dist) || - typeof data.dist.tarball !== "string" || - typeof data.dist.integrity !== "string" - ) { - throw new Error(`${pkg.name}@${pkg.version}: npm registry returned invalid metadata`); - } - - const tarball = await fetch(data.dist.tarball, { method: "HEAD", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); - if (!tarball.ok) { - throw new Error(`${pkg.name}@${pkg.version}: tarball returned ${tarball.status}`); - } - - return { - name: pkg.name, - version: pkg.version, - tarball: data.dist.tarball, - integrity: data.dist.integrity, - }; -} - async function verifyPackagesAreAvailable(packages) { const deadline = Date.now() + RETRY_TIMEOUT_MS; let attempt = 0; @@ -97,7 +69,7 @@ async function verifyPackagesAreAvailable(packages) { do { attempt++; - const results = await Promise.allSettled(packages.map(resolvePackageFromNpm)); + const results = await Promise.allSettled(packages.map((pkg) => verifyNpmArtifact(pkg))); failures = results.flatMap((result, index) => result.status === "rejected" ? [`${packages[index].name}: ${result.reason}`] : [], ); @@ -233,19 +205,15 @@ async function main() { "Release publication requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the protected release environment.", ); } - const packages = getPublicWorkspacePackages(); - if (packages.length === 0) throw new Error("No public workspace packages found for verification."); - for (const pkg of packages) { - if (pkg.version !== options.version) { - throw new Error(`${pkg.name} is ${pkg.version}; expected ${options.version}`); - } - } + const sourceCommit = options.sourceCommit ?? gitSourceCommit(); + const manifest = readReleaseArtifacts(options.artifacts, { version: options.version, sourceCommit }); + const packages = manifest.packages; const publishedPackages = await verifyPackagesAreAvailable(packages); const release = { schemaVersion: 1, version: options.version, - sourceCommit: options.sourceCommit ?? gitSourceCommit(), + sourceCommit, publishedAt: new Date().toISOString(), packages: publishedPackages, }; @@ -301,14 +269,7 @@ async function main() { join(temporaryDirectory, "latest-current.json"), ), (condition) => - putJson( - options.bucket, - options.endpoint, - releasePath, - `${RELEASES_PREFIX}/latest.json`, - "no-store", - condition, - ), + putJson(options.bucket, options.endpoint, releasePath, `${RELEASES_PREFIX}/latest.json`, "no-store", condition), ); console.log( result.advanced diff --git a/scripts/publish-release-announcement.test.mjs b/scripts/publish-release-announcement.test.mjs index d7458910e..948294c52 100644 --- a/scripts/publish-release-announcement.test.mjs +++ b/scripts/publish-release-announcement.test.mjs @@ -74,7 +74,7 @@ test("publisher rejects missing credentials before package verification or stora } }); -for (const failure of ["package", "tarball"]) { +for (const failure of ["package", "tarball", "integrity", "bytes"]) { test(`publisher leaves both records untouched when ${failure} verification fails`, () => { const fixture = createPublicationFixture(); try { @@ -199,7 +199,109 @@ test("publisher rejects a noncanonical source commit before storing records", () try { const result = fixture.run("0.0.3", { commit: "not-a-commit" }); assert.equal(result.status, 1); - assert.match(result.stderr, /Invalid release record/); + assert.match(result.stderr, /Artifact source|Invalid release record/); + assert.deepEqual(fixture.state().objects, {}); + } finally { + fixture.cleanup(); + } +}); + +test("version record survives an actual pointer-write failure and recovery preserves its bytes", () => { + const fixture = createPublicationFixture(); + try { + fixture.seed({}, "PointerFailure"); + const failed = fixture.run("0.0.3"); + assert.equal(failed.status, 1); + const versionKey = "releases/v1/releases/0.0.3.json"; + const record = fixture.state().objects[versionKey]; + assert.ok(record); + assert.equal(fixture.state().objects["releases/v1/latest.json"], undefined); + fixture.seed({ [versionKey]: record }); + const retry = fixture.run("0.0.3"); + assert.equal(retry.status, 0, retry.stderr); + assert.equal(fixture.state().objects["releases/v1/latest.json"], record); + } finally { + fixture.cleanup(); + } +}); + +test("partial npm publication resumes original bytes and never republishes completed versions", () => { + const fixture = createPublicationFixture(); + try { + const failed = fixture.run("0.0.3", { command: "publish", missing: true, failPublishAt: "@diffexai/diffex" }); + assert.equal(failed.status, 1, failed.stdout); + const published = fixture.state().published; + assert.ok(published.length > 0 && published.length < fixture.packageCount); + assert.deepEqual(fixture.state().objects, {}); + const retry = fixture.run("0.0.3", { command: "publish", missing: true }); + assert.equal(retry.status, 0, retry.stderr); + assert.equal(fixture.state().published.length, fixture.packageCount); + assert.equal(fixture.run("0.0.3", { command: "publish", missing: true }).status, 0); + assert.equal(fixture.state().published.length, fixture.packageCount); + assert.equal(fixture.run("0.0.3", { missing: true }).status, 0); + assert.ok(fixture.state().objects["releases/v1/latest.json"]); + } finally { + fixture.cleanup(); + } +}); + +for (const failure of ["bytes", "integrity", "tarball"]) + test(`existing npm ${failure} failure blocks every publish and announcement`, () => { + const fixture = createPublicationFixture(); + try { + const result = fixture.run("0.0.3", { command: "publish", failure }); + assert.equal(result.status, 1); + assert.equal(fixture.state().published, undefined); + assert.deepEqual(fixture.state().objects, {}); + } finally { + fixture.cleanup(); + } + }); + +for (const mutateManifest of [ + (manifest) => { + manifest.source.commit = "b".repeat(40); + }, + (manifest) => { + manifest.source.lockSha256 = "b".repeat(64); + }, + (manifest) => { + manifest.packages[0].sha256 = "b".repeat(64); + }, + (manifest) => { + manifest.snapshot.sha256 = "b".repeat(64); + }, + (manifest) => { + manifest.packages.pop(); + }, + (manifest) => { + manifest.version = "0.0.4"; + }, +]) + test("artifact source, inventory, and byte tampering fail before network or storage", () => { + const fixture = createPublicationFixture(); + try { + const result = fixture.run("0.0.3", { mutateManifest }); + assert.equal(result.status, 1); + assert.equal(fixture.state().registryCalls, undefined); + assert.deepEqual(fixture.state().calls, []); + } finally { + fixture.cleanup(); + } + }); + +test("enabling public binary assets without an availability gate fails before promotion", () => { + const fixture = createPublicationFixture(); + try { + const result = fixture.run("0.0.3", { + mutatePolicy: (policy) => { + policy.channels.standalone = "enable"; + return policy; + }, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /public asset verification/); + assert.equal(fixture.state().registryCalls, undefined); assert.deepEqual(fixture.state().objects, {}); } finally { fixture.cleanup(); diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 5d05158c5..ed1edaca6 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { packReviewedPackage } from "./pack-reviewed-package.mjs"; +import { existsSync } from "node:fs"; +import { prepareReleaseArtifacts, readReleaseArtifacts } from "./release-artifacts.mjs"; +import { verifyNpmArtifact } from "./release-registry.mjs"; import { join } from "node:path"; import { assertBootstrapPackageStates, @@ -49,30 +49,6 @@ function run(command, args, options = {}) { return result; } -function assertBuildOutputExists(directory) { - if (!existsSync(join(directory, "dist"))) { - throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`); - } -} - -function isPublished(name, version) { - const result = spawnSync(commandForPlatform("npm"), ["view", `${name}@${version}`, "version", "--json"], { - encoding: "utf8", - stdio: ["inherit", "pipe", "pipe"], - }); - - if (result.status === 0 && result.stdout.trim()) { - return true; - } - - const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); - if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) { - return false; - } - - throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`); -} - function isRegistered(name) { const result = spawnSync(commandForPlatform("npm"), ["view", name, "version", "--json"], { encoding: "utf8", @@ -110,32 +86,24 @@ if (bootstrap) { ); } -// Validate every final tarball before registry queries or any publication. -const packDirectory = mkdtempSync(join(tmpdir(), "diffex-approved-npm-")); -process.on("exit", () => rmSync(packDirectory, { recursive: true, force: true })); -const packedPackages = new Map(); -for (const pkg of packages) { - assertBuildOutputExists(pkg.directory); - packedPackages.set(pkg.name, packReviewedPackage(pkg, packDirectory)); +const artifactDirectory = options.artifacts ?? (bootstrap ? ".artifacts/npm-bootstrap" : undefined); +if (!artifactDirectory) throw new Error("Normal publication requires --artifacts DIRECTORY from the retained CI build"); +if (bootstrap && !existsSync(artifactDirectory)) prepareReleaseArtifacts(artifactDirectory); +const manifest = readReleaseArtifacts(artifactDirectory, { + version: versions[0], + sourceCommit: process.env.DIFFEX_SOURCE_COMMIT, +}); +// Verify every existing version before publishing any missing version. +// A registry success or matching version string alone never authorizes a skip. +const packageStates = []; +for (const pkg of manifest.packages) { + packageStates.push({ + ...pkg, + published: Boolean(await verifyNpmArtifact(pkg, { allowMissing: true })), + registered: false, + }); } -mkdirSync(".artifacts", { recursive: true }); -writeFileSync( - ".artifacts/npm-payload-inventory.json", - `${JSON.stringify( - [...packedPackages.values()].map(({ file, ...artifact }) => ({ ...artifact, filename: file.split("/").pop() })), - null, - 2, - )}\n`, -); - -const packageStates = packages.map((pkg) => ({ - ...pkg, - published: isPublished(pkg.name, packageVersions.get(pkg.name)), - registered: false, - version: packageVersions.get(pkg.name), -})); - if (bootstrap) { for (const pkg of packageStates) { pkg.registered = pkg.published || isRegistered(pkg.name); @@ -144,14 +112,12 @@ if (bootstrap) { } for (const pkg of packageStates) { - assertBuildOutputExists(pkg.directory); - if (pkg.published) { - console.log(`${pkg.name}@${pkg.version} is already published; validating package contents only.`); + console.log(`${pkg.name}@${pkg.version} is already published; verified exact published tarball bytes.`); } else { console.log(`${pkg.name}@${pkg.version} is not published; validating package contents before publish.`); } - console.log(` Validated tarball SHA-256: ${packedPackages.get(pkg.name).sha256}`); + console.log(` Validated tarball SHA-256: ${pkg.sha256}`); console.log(); } @@ -174,7 +140,7 @@ for (const pkg of packageStates) { continue; } - run("npm", [...createPublishArgs({ bootstrap }), packedPackages.get(pkg.name).file]); + run("npm", [...createPublishArgs({ bootstrap }), join(artifactDirectory, pkg.filename)]); console.log(); } diff --git a/scripts/release-artifacts.mjs b/scripts/release-artifacts.mjs new file mode 100644 index 000000000..80bcff103 --- /dev/null +++ b/scripts/release-artifacts.mjs @@ -0,0 +1,188 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import { packReviewedPackage } from "./pack-reviewed-package.mjs"; +import { integrity, sha256 } from "./release-registry.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; +import { assertPackageVersion, git, isStableVersion } from "./release-source.mjs"; + +export function assertEnabledChannels(policy) { + if ( + policy.status !== "approved" || + policy.sourceRepository !== "diffexai/diffex" || + policy.publicRegistry !== "https://registry.npmjs.org" || + policy.publicHosting !== "npmjs" || + policy.channels?.npmjs !== "enable" || + Object.entries(policy.channels).some(([name, value]) => name !== "npmjs" && value !== "defer") + ) + throw new Error( + "Unapproved distribution channels: public asset verification must be implemented before enabling another channel", + ); +} + +function sourceIdentity() { + const policyBytes = readFileSync("docs/release/distribution-policy.json"); + assertEnabledChannels(JSON.parse(policyBytes)); + return { + repository: "diffexai/diffex", + commit: git("rev-parse", "HEAD"), + tree: git("rev-parse", "HEAD^{tree}"), + lockSha256: sha256(readFileSync("package-lock.json")), + policySha256: sha256(policyBytes), + }; +} + +export function readReleaseArtifacts(directory, { version, sourceCommit } = {}) { + const manifest = JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8")); + const identity = sourceIdentity(); + if ( + !/^[a-f0-9]{40}$/.test(identity.commit) || + !isStableVersion(manifest.version) || + manifest.schemaVersion !== 1 || + !isDeepStrictEqual(manifest.source, identity) || + (sourceCommit && identity.commit !== sourceCommit) || + (version && manifest.version !== version) + ) + throw new Error("Artifact source/version conflicts with the checked release commit and inputs"); + const packages = assertPackageVersion(manifest.version); + if ( + !Array.isArray(manifest.packages) || + manifest.packages.length !== packages.length || + new Set(manifest.packages.map((pkg) => pkg.name)).size !== packages.length + ) + throw new Error("Artifact inventory must contain every public package exactly once"); + for (const pkg of packages) { + const artifact = manifest.packages.find((artifact) => artifact.name === pkg.name); + if ( + !artifact || + artifact.version !== pkg.version || + artifact.filename !== `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz` || + !/^[a-f0-9]{64}$/.test(artifact.sha256) || + !/^sha512-[A-Za-z0-9+/]{86}==$/.test(artifact.integrity) || + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 + ) + throw new Error(`Invalid approved artifact: ${pkg.name}`); + { + const bytes = readFileSync(join(directory, artifact.filename)); + if ( + bytes.length !== artifact.size || + sha256(bytes) !== artifact.sha256 || + integrity(bytes) !== artifact.integrity + ) + throw new Error(`Retained tarball identity mismatch: ${pkg.name}`); + } + } + if ( + manifest.snapshot?.filename !== "source.tar.gz" || + !/^[a-f0-9]{64}$/.test(manifest.snapshot.sha256) || + !Array.isArray(manifest.modelData) || + !manifest.modelData.length + ) + throw new Error("Missing frozen source/model snapshot"); + if (sha256(readFileSync(join(directory, "source.tar.gz"))) !== manifest.snapshot.sha256) + throw new Error("Frozen source snapshot integrity mismatch"); + const inputs = JSON.parse(readFileSync(join(directory, "inputs.json"), "utf8")); + const { schemaVersion, packages: artifacts, ...recordedInputs } = manifest; + if (!isDeepStrictEqual(inputs, recordedInputs)) + throw new Error("Approved manifest differs from the frozen build inputs"); + return manifest; +} + +function modelSnapshot() { + const modelRoot = "packages/ai/src/providers/data"; + return readdirSync(modelRoot) + .filter((file) => file.endsWith(".json")) + .sort() + .map((file) => ({ path: `${modelRoot}/${file}`, sha256: sha256(readFileSync(join(modelRoot, file))) })); +} + +export function freezeReleaseInputs(directory) { + if (existsSync(directory)) + throw new Error("Artifact directory already exists; verify and reuse it instead of rebuilding"); + if (git("status", "--porcelain", "--untracked-files=all")) + throw new Error("Artifact preparation requires clean tracked source and no untracked files"); + const source = sourceIdentity(); + const version = getPublicWorkspacePackages()[0]?.version; + assertPackageVersion(version); + if (process.env.DIFFEX_SOURCE_COMMIT && process.env.DIFFEX_SOURCE_COMMIT !== source.commit) + throw new Error("Build source differs from resolved release commit"); + mkdirSync(directory, { recursive: true }); + const modelData = modelSnapshot(); + execFileSync( + "bash", + [ + "scripts/create-source-archive.sh", + "--version", + version, + "--ref", + source.commit, + "--out", + resolve(directory, "source.tar.gz"), + ], + { stdio: "inherit" }, + ); + const inputs = { + version, + source, + modelData, + snapshot: { filename: "source.tar.gz", sha256: sha256(readFileSync(join(directory, "source.tar.gz"))) }, + toolchain: { + node: process.version, + npm: execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(), + platform: process.platform, + arch: process.arch, + }, + }; + writeFileSync(join(directory, "inputs.json"), `${JSON.stringify(inputs, null, 2)}\n`, { flag: "wx" }); + return inputs; +} + +export function prepareReleaseArtifacts(directory) { + if (!existsSync(directory)) freezeReleaseInputs(directory); + if (existsSync(join(directory, "manifest.json"))) + throw new Error("Approved artifacts already exist; never repack a published version"); + const inputs = JSON.parse(readFileSync(join(directory, "inputs.json"), "utf8")); + if ( + !isDeepStrictEqual(inputs.source, sourceIdentity()) || + !isDeepStrictEqual(inputs.modelData, modelSnapshot()) || + sha256(readFileSync(join(directory, "source.tar.gz"))) !== inputs.snapshot.sha256 + ) + throw new Error("Frozen release inputs changed during the build"); + const packages = assertPackageVersion(inputs.version); + const artifacts = packages.map((pkg) => { + const packed = packReviewedPackage(pkg, directory); + const bytes = readFileSync(packed.file); + return { + name: pkg.name, + version: inputs.version, + filename: basename(packed.file), + size: bytes.length, + sha256: packed.sha256, + integrity: integrity(bytes), + }; + }); + if (git("diff", "--name-only", "HEAD") || !isDeepStrictEqual(inputs.modelData, modelSnapshot())) + throw new Error("Build or packing changed release inputs"); + const manifest = { schemaVersion: 1, ...inputs, packages: artifacts }; + writeFileSync(join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }); + return readReleaseArtifacts(directory); +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [command, directory] = process.argv.slice(2); + if (!directory || !["freeze", "prepare", "verify"].includes(command)) + throw new Error("Usage: node scripts/release-artifacts.mjs DIRECTORY"); + const manifest = + command === "freeze" + ? freezeReleaseInputs(directory) + : command === "prepare" + ? prepareReleaseArtifacts(directory) + : readReleaseArtifacts(directory, { + sourceCommit: process.env.DIFFEX_SOURCE_COMMIT, + version: process.env.RELEASE_TAG?.slice(1), + }); + console.log(`Verified ${manifest.packages?.length ?? 0} retained artifacts for ${manifest.source.commit}`); +} diff --git a/scripts/release-registry.mjs b/scripts/release-registry.mjs new file mode 100644 index 000000000..f60504ced --- /dev/null +++ b/scripts/release-registry.mjs @@ -0,0 +1,41 @@ +import { createHash } from "node:crypto"; + +const REGISTRY = "https://registry.npmjs.org"; +const TIMEOUT = 30_000; +const MAX_TARBALL_BYTES = 256 * 1024 * 1024; +export const integrity = (bytes) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +export async function verifyNpmArtifact(pkg, { allowMissing = false } = {}) { + const response = await fetch(`${REGISTRY}/${encodeURIComponent(pkg.name)}/${pkg.version}`, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(TIMEOUT), + }); + if (response.status === 404 && allowMissing) return undefined; + if (!response.ok) throw new Error(`${pkg.name}: npm metadata unavailable (${response.status})`); + const metadata = await response.json(); + const expectedUrl = `${REGISTRY}/${pkg.name}/-/${pkg.name.split("/").pop()}-${pkg.version}.tgz`; + if ( + metadata?.name !== pkg.name || + metadata?.version !== pkg.version || + metadata?.dist?.tarball !== expectedUrl || + metadata?.dist?.integrity !== pkg.integrity + ) + throw new Error(`${pkg.name}: published metadata conflicts with approved artifact`); + const tarball = await fetch(expectedUrl, { redirect: "error", signal: AbortSignal.timeout(TIMEOUT) }); + if (!tarball.ok) throw new Error(`${pkg.name}: tarball unavailable (${tarball.status})`); + if (!tarball.body) throw new Error(`${pkg.name}: missing tarball body`); + const sha = createHash("sha256"); + const sri = createHash("sha512"); + let size = 0; + for await (const chunk of tarball.body) { + size += chunk.length; + if (size > MAX_TARBALL_BYTES) throw new Error(`${pkg.name}: tarball exceeds size limit`); + sha.update(chunk); + sri.update(chunk); + } + if (size !== pkg.size || sha.digest("hex") !== pkg.sha256 || `sha512-${sri.digest("base64")}` !== pkg.integrity) + throw new Error(`${pkg.name}: published tarball bytes conflict with approved artifact`); + return { name: pkg.name, version: pkg.version, tarball: expectedUrl, integrity: pkg.integrity }; +} diff --git a/scripts/release-source.mjs b/scripts/release-source.mjs new file mode 100644 index 000000000..8781562ed --- /dev/null +++ b/scripts/release-source.mjs @@ -0,0 +1,139 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +import { isStableVersion } from "../packages/coding-agent/src/utils/release-contract.ts"; +export { isStableVersion }; +export const git = (...args) => + execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + +export function assertCanonicalOrigin() { + const allowed = new Set([ + "https://github.com/diffexai/diffex.git", + "https://github.com/diffexai/diffex", + "git@github.com:diffexai/diffex.git", + "ssh://git@github.com/diffexai/diffex.git", + ]); + for (const args of [ + ["remote", "get-url", "--all", "origin"], + ["remote", "get-url", "--push", "--all", "origin"], + ]) { + const urls = git(...args).split("\n"); + if (urls.length !== 1 || !allowed.has(urls[0])) + throw new Error("Release requires the canonical diffexai/diffex origin for fetch and push"); + } +} + +export function assertCleanMain() { + if (git("status", "--porcelain", "--untracked-files=all")) throw new Error("Release requires a clean working tree"); + if (git("branch", "--show-current") !== "main") throw new Error("Release requires main"); +} + +export function assertPackageVersion(version) { + if (!isStableVersion(version)) throw new Error("Release requires a canonical stable version"); + const packages = getPublicWorkspacePackages(); + if (!packages.length || packages.some((pkg) => pkg.version !== version)) + throw new Error("Package versions do not match the release version"); + return packages; +} + +export function targetVersion(current, target) { + if (!isStableVersion(current) || !["patch", "minor"].includes(target)) + throw new Error( + "Use patch for fixes/additions or minor for breaking changes; major and explicit releases are unsupported", + ); + const [major, minor, patch] = current.split(".").map(BigInt); + return target === "patch" ? `${major}.${minor}.${patch + 1n}` : `${major}.${minor + 1n}.0`; +} + +export function preflightRelease(target) { + assertCanonicalOrigin(); + assertCleanMain(); + const current = getPublicWorkspacePackages()[0]?.version; + assertPackageVersion(current); + const version = targetVersion(current, target); + git("fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"); + if (git("rev-parse", "HEAD") !== git("rev-parse", "origin/main")) + throw new Error("HEAD must equal refreshed origin/main"); + if ( + git("tag", "--list", `v${version}`) || + git("ls-remote", "--tags", "origin", `refs/tags/v${version}`, `refs/tags/v${version}^{}`) + ) + throw new Error(`Release tag v${version} already exists locally or remotely`); + return version; +} + +export function resolveReleaseSource(tag, recoveryRef, expectedCommit) { + assertCanonicalOrigin(); + if (!tag?.startsWith("v") || !isStableVersion(tag.slice(1))) throw new Error("Invalid stable release tag"); + // Fetch the exact tag once; downstream jobs use only the resulting object ID. + git("fetch", "--no-tags", "origin", `refs/tags/${tag}`); + const commit = git("rev-parse", "FETCH_HEAD^{commit}"); + if (expectedCommit && commit !== expectedCommit) + throw new Error("Release tag differs from the original event commit"); + if (recoveryRef) { + if (recoveryRef.startsWith("-") || /[\s\x00-\x1f]/.test(recoveryRef)) throw new Error("Invalid recovery source"); + git("fetch", "--no-tags", "origin", recoveryRef); + if (git("rev-parse", "FETCH_HEAD^{commit}") !== commit) + throw new Error("Divergent recovery source; the release tag is immutable"); + } + git("fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"); + git("merge-base", "--is-ancestor", commit, "origin/main"); + let publicPackages = 0; + for (const path of git("ls-tree", "-r", "--name-only", commit, "packages") + .split("\n") + .filter((path) => path.endsWith("/package.json"))) { + const pkg = JSON.parse(git("show", `${commit}:${path}`)); + if (pkg.private !== true) { + publicPackages++; + if (pkg.version !== tag.slice(1)) throw new Error(`Package/tag version mismatch: ${path}`); + } + } + if (!publicPackages) throw new Error("No public release packages"); + return commit; +} + +export function pushPreparedRelease(tag) { + assertCanonicalOrigin(); + assertCleanMain(); + if (!tag?.startsWith("v") || !isStableVersion(tag.slice(1))) throw new Error("Invalid stable release tag"); + assertPackageVersion(tag.slice(1)); + const commit = git("rev-parse", `refs/tags/${tag}^{commit}`); + if ( + git("rev-parse", "HEAD^") !== commit || + git("log", "-1", "--format=%s") !== "Add [Unreleased] section for next cycle" + ) + throw new Error("Prepared main must contain exactly the next-cycle commit after the release tag"); + const changed = git("diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD").split("\n"); + if (changed.some((path) => !/^packages\/.+\/CHANGELOG\.md$/.test(path))) + throw new Error("Unexpected next-cycle changes"); + git("fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"); + git("merge-base", "--is-ancestor", "origin/main", "main"); + const remoteTag = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`); + if ( + remoteTag && + !remoteTag + .split("\n") + .some((line) => line === `${commit}\trefs/tags/${tag}^{}` || line === `${commit}\trefs/tags/${tag}`) + ) + throw new Error("Remote release tag conflicts; never rewrite it"); + try { + git("push", "--atomic", "origin", "refs/heads/main:refs/heads/main", `refs/tags/${tag}:refs/tags/${tag}`); + } catch { + throw new Error( + `Atomic push did not confirm completion. Keep both local commits and ${tag}; inspect remote refs and rerun node scripts/release.mjs --push-prepared ${tag}. See docs/release/promotion.md for unsupported-server recovery. Never recreate the release or force-push.`, + ); + } +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const commit = resolveReleaseSource( + process.env.RELEASE_TAG, + process.env.SOURCE_REF, + process.env.EXPECTED_COMMIT || undefined, + ); + if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required"); + appendFileSync(process.env.GITHUB_OUTPUT, `commit=${commit}\n`); + console.log(`Verified release source ${commit}`); +} diff --git a/scripts/release-source.test.mjs b/scripts/release-source.test.mjs new file mode 100644 index 000000000..4972bf32f --- /dev/null +++ b/scripts/release-source.test.mjs @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; +import { assertPackageVersion, targetVersion } from "./release-source.mjs"; + +const release = fileURLToPath(new URL("./release.mjs", import.meta.url)); +const source = fileURLToPath(new URL("./release-source.mjs", import.meta.url)); +const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "diffex-source-test-")); + const cwd = join(root, "checkout"); + const remote = join(root, "origin.git"); + mkdirSync(cwd); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "Fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }; + const git = (...args) => + execFileSync(realGit, args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + git("init", "--bare", "--initial-branch=main", remote); + git("init", "--initial-branch=main"); + git("remote", "add", "origin", remote); + writeFileSync(join(cwd, "package.json"), JSON.stringify({ private: true, version: "9.9.9" })); + mkdirSync(join(cwd, "packages/ai"), { recursive: true }); + writeFileSync( + join(cwd, "packages/ai/package.json"), + JSON.stringify({ name: "@diffexai/diffex-ai", version: "0.0.3" }), + ); + writeFileSync(join(cwd, "packages/ai/CHANGELOG.md"), "# Changelog\n\n## [0.0.3]\n\n- Fixture\n"); + mkdirSync(join(cwd, "packages/coding-agent"), { recursive: true }); + writeFileSync( + join(cwd, "packages/coding-agent/package.json"), + JSON.stringify({ name: "@diffexai/diffex", version: "0.0.3" }), + ); + git( + "add", + "package.json", + "packages/ai/package.json", + "packages/ai/CHANGELOG.md", + "packages/coding-agent/package.json", + ); + git("commit", "-m", "fixture"); + git("push", "-u", "origin", "main"); + const bin = join(root, "bin"); + mkdirSync(bin); + const log = join(root, "commands.jsonl"); + const marker = join(root, "lose-ack"); + writeFileSync( + join(bin, "git"), + `#!/usr/bin/env node +import {spawnSync} from 'node:child_process'; +import {appendFileSync,existsSync,unlinkSync} from 'node:fs'; +const args=process.argv.slice(2); +appendFileSync(${JSON.stringify(log)},JSON.stringify(args)+'\\n'); +if(args[0]==='remote' && args[1]==='get-url') { console.log(process.env.DIFFEX_FIXTURE_ORIGIN || 'https://github.com/diffexai/diffex.git'); } +else { const result=spawnSync(${JSON.stringify(realGit)},args,{stdio:'inherit'}); if(args[0]==='push' && existsSync(${JSON.stringify(marker)})) {unlinkSync(${JSON.stringify(marker)});process.exit(1);} process.exit(result.status??1); } +`, + { mode: 0o755 }, + ); + // Executables have no extension, so set module mode in their own parent. + writeFileSync(join(bin, "package.json"), '{"type":"module"}'); + writeFileSync(join(bin, "npm"), "#!/bin/sh\necho NPM_PREFLIGHT_REACHED >&2\nexit 1\n", { mode: 0o755 }); + return { + cwd, + root, + remote, + git, + marker, + run: (args = ["patch"], overrides = {}) => + spawnSync(process.execPath, [release, ...args], { + cwd, + env: { ...env, PATH: `${bin}:${env.PATH}`, ...overrides }, + encoding: "utf8", + timeout: 15000, + }), + resolve: (tag, ref, expected) => + spawnSync(process.execPath, [source], { + cwd, + env: { + ...env, + PATH: `${bin}:${env.PATH}`, + RELEASE_TAG: tag, + SOURCE_REF: ref ?? "", + EXPECTED_COMMIT: expected ?? "", + GITHUB_OUTPUT: join(root, "output"), + }, + encoding: "utf8", + timeout: 15000, + }), + calls: () => readFileSync(log, "utf8").trim().split("\n").map(JSON.parse), + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +for (const [name, setup, message] of [ + ["dirty tree", (f) => writeFileSync(join(f.cwd, "unreviewed"), "dirty"), /clean working tree/], + ["wrong branch", (f) => f.git("switch", "-c", "feature"), /requires main/], + ["unpublished local commit", (f) => f.git("commit", "--allow-empty", "-m", "local"), /HEAD must equal refreshed/], + [ + "stale remote state", + (f) => { + const next = f.git("commit-tree", "HEAD^{tree}", "-p", "HEAD", "-m", "remote change"); + f.git("push", f.remote, `${next}:refs/heads/main`); + }, + /HEAD must equal refreshed/, + ], + ["existing local tag", (f) => f.git("tag", "v0.0.4"), /tag v0.0.4 already exists/], + [ + "existing remote tag", + (f) => f.git("--git-dir", f.remote, "update-ref", "refs/tags/v0.0.4", f.git("rev-parse", "HEAD")), + /tag v0.0.4 already exists/, + ], + [ + "mismatched package versions", + (f) => { + writeFileSync( + join(f.cwd, "packages/ai/package.json"), + JSON.stringify({ name: "@diffexai/diffex-ai", version: "0.0.2" }), + ); + f.git("add", "packages/ai/package.json"); + f.git("commit", "-m", "mismatch"); + f.git("push", "origin", "main"); + }, + /Package versions do not match/, + ], +]) { + test(`release command rejects ${name} before npm or remote writes`, () => { + const f = fixture(); + try { + setup(f); + const result = f.run(); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, message); + assert.doesNotMatch(result.stderr, /NPM_PREFLIGHT_REACHED/); + assert(!f.calls().some((args) => args[0] === "push")); + } finally { + f.cleanup(); + } + }); +} +test("release command rejects foreign and multiple origin URLs", () => { + for (const origin of [ + "https://github.com/other/diffex.git", + "https://github.com/diffexai/diffex.git\nhttps://github.com/other/diffex.git", + ]) { + const f = fixture(); + try { + const result = f.run(["patch"], { DIFFEX_FIXTURE_ORIGIN: origin }); + assert.match(result.stderr, /canonical diffexai/); + assert(!f.calls().some((args) => ["fetch", "push"].includes(args[0]))); + } finally { + f.cleanup(); + } + } +}); +test("synchronized main reaches the npm preflight without mutating source", () => { + const f = fixture(); + try { + const before = f.git("rev-parse", "HEAD"); + const result = f.run(); + assert.match(result.stderr, /NPM_PREFLIGHT_REACHED/); + assert.equal(f.git("status", "--porcelain"), ""); + assert.equal(f.git("rev-parse", "HEAD"), before); + } finally { + f.cleanup(); + } +}); +test("only patch and minor commands are supported", () => { + assert.equal(targetVersion("0.3.9", "patch"), "0.3.10"); + assert.equal(targetVersion("0.3.9", "minor"), "0.4.0"); + for (const version of ["0.3.9\n", "01.3.9", "0.3.9-beta.1"]) assert.throws(() => targetVersion(version, "patch")); + for (const target of ["major", "1.0.0", "01.2.3", "1.2.3-beta.1"]) + assert.throws(() => targetVersion("0.3.9", target)); + const f = fixture(); + try { + for (const target of ["major", "1.0.0"]) assert.match(f.run([target]).stderr, /unsupported/); + } finally { + f.cleanup(); + } +}); +test("CI resolves the tag once and rejects divergent recovery or event commits", () => { + const f = fixture(); + try { + const commit = f.git("rev-parse", "HEAD"); + f.git("--git-dir", f.remote, "update-ref", "refs/tags/v0.0.3", commit); + const divergent = f.git("commit-tree", "HEAD^{tree}", "-p", "HEAD", "-m", "other source"); + f.git("push", f.remote, `${divergent}:refs/heads/recovery`); + let result = f.resolve("v0.0.3", "refs/heads/recovery"); + assert.match(result.stderr, /Divergent recovery source/); + result = f.resolve("v0.0.3", undefined, "b".repeat(40)); + assert.match(result.stderr, /original event commit/); + result = f.resolve("v0.0.3", "refs/heads/main", commit); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(f.root, "output"), "utf8"), `commit=${commit}\n`); + f.git("--git-dir", f.remote, "update-ref", "refs/tags/v0.0.4", commit); + assert.match(f.resolve("v0.0.4").stderr, /Package\/tag version mismatch/); + assert(!f.calls().some((args) => args[0] === "push")); + } finally { + f.cleanup(); + } +}); +for (const failure of ["rejected transaction", "lost acknowledgement"]) + test(`atomic push recovers from ${failure} without recreating commits or tags`, () => { + const f = fixture(); + try { + const before = f.git("rev-parse", "HEAD"); + f.git("tag", "v0.0.3"); + writeFileSync( + join(f.cwd, "packages/ai/CHANGELOG.md"), + "# Changelog\n\n## [Unreleased]\n\n## [0.0.3]\n\n- Fixture\n", + ); + f.git("add", "packages/ai/CHANGELOG.md"); + f.git("commit", "-m", "Add [Unreleased] section for next cycle"); + const final = f.git("rev-parse", "HEAD"); + const hook = join(f.remote, "hooks/pre-receive"); + if (failure === "rejected transaction") writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + else writeFileSync(f.marker, "lose response"); + let result = f.run(["--push-prepared", "v0.0.3"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /Atomic push did not confirm/); + if (failure === "rejected transaction") { + assert.equal(f.git("--git-dir", f.remote, "rev-parse", "main"), before); + assert.equal(f.git("--git-dir", f.remote, "tag", "--list"), ""); + rmSync(hook); + } + result = f.run(["--push-prepared", "v0.0.3"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(f.git("--git-dir", f.remote, "rev-parse", "main"), final); + assert.equal(f.git("--git-dir", f.remote, "rev-parse", "v0.0.3"), before); + result = f.run(["--push-prepared", "v0.0.3"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(f.git("rev-parse", "HEAD"), final); + } finally { + f.cleanup(); + } + }); + +test("the actual release inventory is independent of the private monorepo version", () => { + const packages = getPublicWorkspacePackages(); + assert.doesNotThrow(() => assertPackageVersion(packages[0].version)); +}); diff --git a/scripts/release.mjs b/scripts/release.mjs index 460d55dea..9d6ea8987 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,37 +1,24 @@ #!/usr/bin/env node -/** - * Release script for Diffex - * - * Usage: - * node scripts/release.mjs - * node scripts/release.mjs - * - * Steps: - * 1. Check for uncommitted changes - * 2. Verify every public workspace package is registered on npm - * 3. Bump version via npm run version:xxx or set an explicit version - * 4. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 5. Regenerate release artifacts - * 6. Run checks and tests - * 7. Commit and tag the release - * 8. Add new [Unreleased] section to changelogs - * 9. Commit next-cycle changelog updates - * 10. Push main and the tag to trigger CI publication and verified Diffex release announcement - */ - -import { execSync, spawnSync } from "node:child_process"; +// Prepare a patch or minor release from synchronized main, review generated +// changes, and atomically publish the release and next-cycle commits. + +import { execFileSync, execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { findPackageDirectories } from "./package-workspaces.mjs"; import { getPublicWorkspacePackages } from "./release-packages.mjs"; +import { createInterface } from "node:readline/promises"; +import { preflightRelease, pushPreparedRelease, assertPackageVersion } from "./release-source.mjs"; -const RELEASE_TARGET = process.argv[2]; -const BUMP_TYPES = new Set(["major", "minor", "patch"]); -const SEMVER_RE = /^\d+\.\d+\.\d+$/; +if (process.argv[2] === "--push-prepared") { + if (process.argv.length !== 4) throw new Error("Usage: node scripts/release.mjs --push-prepared vX.Y.Z"); + pushPreparedRelease(process.argv[3]); + process.exit(0); +} -if (!RELEASE_TARGET || (!BUMP_TYPES.has(RELEASE_TARGET) && !SEMVER_RE.test(RELEASE_TARGET))) { - console.error("Usage: node scripts/release.mjs "); - process.exit(1); +const RELEASE_TARGET = process.argv[2]; +if (process.argv.length !== 3 || !["patch", "minor"].includes(RELEASE_TARGET)) { + throw new Error("Usage: node scripts/release.mjs ; major and explicit versions are unsupported"); } function run(cmd, options = {}) { @@ -58,10 +45,14 @@ function assertPackagesAreRegisteredWithNpm() { console.log("Checking npm package registration..."); for (const packageName of packageNames) { - const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["view", packageName, "version", "--json"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + const result = spawnSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["view", packageName, "version", "--json"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); if (result.status === 0 && result.stdout.trim()) { console.log(` ${packageName}`); @@ -74,38 +65,24 @@ function assertPackagesAreRegisteredWithNpm() { continue; } - throw new Error(output ? `Failed to query npm registration for ${packageName}\n${output}` : `Failed to query npm registration for ${packageName}`); + throw new Error( + output + ? `Failed to query npm registration for ${packageName}\n${output}` + : `Failed to query npm registration for ${packageName}`, + ); } if (unregisteredPackages.length > 0) { - throw new Error(`The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nFollow the first-publication process in RELEASING.md before running a release.`); + throw new Error( + `The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nFollow the first-publication process in RELEASING.md before running a release.`, + ); } console.log(" All public workspace packages are registered on npm\n"); } -function compareVersions(a, b) { - const aParts = a.split(".").map(Number); - const bParts = b.split(".").map(Number); - - for (let i = 0; i < 3; i++) { - const diff = (aParts[i] || 0) - (bParts[i] || 0); - if (diff !== 0) { - return diff; - } - } - - return 0; -} - -function shellQuote(value) { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - function removeStaleWorkspaceLockEntries() { - const workspaceVersions = new Map( - getPublicWorkspacePackages().map((pkg) => [pkg.name, pkg.version]), - ); + const workspaceVersions = new Map(getPublicWorkspacePackages().map((pkg) => [pkg.name, pkg.version])); const lockPath = "package-lock.json"; const lock = JSON.parse(readFileSync(lockPath, "utf8")); let removed = 0; @@ -129,35 +106,18 @@ function removeStaleWorkspaceLockEntries() { } } -function stageChangedFiles() { - const output = run("git ls-files -m -o -d --exclude-standard", { silent: true }); - const paths = [...new Set((output || "").split("\n").map((line) => line.trim()).filter(Boolean))]; - if (paths.length === 0) { - return; - } - - run(`git add -- ${paths.map(shellQuote).join(" ")}`); +function stageChangedFiles(allowed) { + const paths = execFileSync("git", ["ls-files", "-m", "-o", "-d", "--exclude-standard", "-z"], { encoding: "utf8" }) + .split("\0") + .filter(Boolean); + const unexpected = paths.filter((path) => !allowed.has(path)); + if (unexpected.length) + throw new Error(`Unexpected generated changes; review before release: ${unexpected.join(", ")}`); + if (paths.length) execFileSync("git", ["add", "--", ...new Set(paths)], { stdio: "inherit" }); } -function bumpOrSetVersion(target) { - const currentVersion = getVersion(); - - if (BUMP_TYPES.has(target)) { - console.log(`Bumping version (${target})...`); - run(`npm run version:${target}`); - } else { - if (compareVersions(target, currentVersion) <= 0) { - console.error(`Error: explicit version ${target} must be greater than current version ${currentVersion}.`); - process.exit(1); - } - - console.log(`Setting explicit version (${target})...`); - run(`npm version ${target} --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`); - } - - // npm version can temporarily install the previous workspace versions before - // sync-versions updates inter-package ranges. Remove those stale lock entries, - // refresh the lockfile, then hydrate from the final dependency graph. +function bumpVersion(target) { + run(`npm run version:${target}`); removeStaleWorkspaceLockEntries(); run("npm install --package-lock-only --ignore-scripts"); run("npm ci --ignore-scripts"); @@ -182,10 +142,7 @@ function updateChangelogsForRelease(version) { continue; } - const updated = content.replace( - "## [Unreleased]", - `## [${version}] - ${date}` - ); + const updated = content.replace("## [Unreleased]", `## [${version}] - ${date}`); writeFileSync(changelog, updated); console.log(` Updated ${changelog}`); } @@ -199,49 +156,37 @@ function addUnreleasedSection() { const content = readFileSync(changelog, "utf-8"); // Insert after "# Changelog\n\n" - const updated = content.replace( - /^(# Changelog\n\n)/, - `$1${unreleasedSection}` - ); + const updated = content.replace(/^(# Changelog\n\n)/, `$1${unreleasedSection}`); writeFileSync(changelog, updated); console.log(` Added [Unreleased] to ${changelog}`); } } -// Main flow -console.log("\n=== Release Script ===\n"); - -// 1. Check for uncommitted changes -console.log("Checking for uncommitted changes..."); -const status = run("git status --porcelain", { silent: true }); -if (status && status.trim()) { - console.error("Error: Uncommitted changes detected. Commit or stash first."); - console.error(status); - process.exit(1); -} -console.log(" Working directory clean\n"); - -// 2. Verify npm package registration before modifying the worktree. +console.log("Validating release source before mutation..."); +const expectedVersion = preflightRelease(RELEASE_TARGET); assertPackagesAreRegisteredWithNpm(); -// 3. Bump or set version -const version = bumpOrSetVersion(RELEASE_TARGET); +// Bump the approved release version +const version = bumpVersion(RELEASE_TARGET); +if (version !== expectedVersion) throw new Error("Version generator did not produce the preflighted release version"); +assertPackageVersion(version); console.log(` New version: ${version}\n`); -// 4. Update changelogs +// Update changelogs console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 5. Regenerate release artifacts +// Regenerate release artifacts console.log("Regenerating release artifacts..."); -run("npm run generate:models"); +// Catalog updates must be reviewed and merged before release preparation. +run("npm run hydrate:model-data"); run("npm run check:model-data"); run("npm run shrinkwrap:coding-agent"); run("npm run install-lock:coding-agent"); console.log(); -// 6. Run checks and tests +// Run checks and tests console.log("Running checks..."); run("npm run check"); console.log(); @@ -254,28 +199,67 @@ console.log("Running tests..."); run("./test.sh"); console.log(); -// 7. Commit and tag +// Commit and tag console.log("Committing and tagging..."); -stageChangedFiles(); +const intendedOutputs = new Set([ + "package.json", + "package-lock.json", + "packages/coding-agent/npm-shrinkwrap.json", + "packages/coding-agent/install-lock/package.json", + "packages/coding-agent/install-lock/package-lock.json", + ...findPackageDirectories().map((directory) => join(directory, "package.json")), + ...getChangelogs(), +]); +run("git diff --check"); +run("git diff --stat"); +run( + "git diff -- package.json package-lock.json packages/coding-agent/npm-shrinkwrap.json packages/coding-agent/install-lock", +); +if (!process.stdin.isTTY) + throw new Error("Review generated diffs in an interactive terminal before committing and pushing"); +const review = createInterface({ input: process.stdin, output: process.stdout }); +try { + if ( + ( + await review.question( + `Review all generated diffs with git diff in another terminal, then type ${version} to commit and push: `, + ) + ).trim() !== version + ) + throw new Error("Release review was not confirmed; changes remain local"); +} finally { + review.close(); +} +// Refresh again after the build/review interval, before creating release refs. +execFileSync("git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"], { + stdio: "inherit", +}); +if ( + execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }) !== + execFileSync("git", ["rev-parse", "origin/main"], { encoding: "utf8" }) +) + throw new Error("origin/main changed during preparation; review the new main before releasing"); +stageChangedFiles(intendedOutputs); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 8. Add new [Unreleased] sections +// Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); console.log(); -// 9. Commit +// Commit console.log("Committing changelog updates..."); -stageChangedFiles(); +stageChangedFiles(new Set(getChangelogs())); run(`git commit -m "Add [Unreleased] section for next cycle"`); console.log(); -// 10. Push +// Push console.log("Pushing to remote..."); -run("git push origin main"); -run(`git push origin v${version}`); +pushPreparedRelease(`v${version}`); console.log(); -console.log(`=== Prepared release v${version}; CI publication and Diffex release announcement start after the tag push ===`); +console.log( + `=== Prepared release v${version}; CI publication and Diffex release announcement start after the tag push ===`, +); diff --git a/scripts/restore-release-artifacts.mjs b/scripts/restore-release-artifacts.mjs new file mode 100644 index 000000000..811c76074 --- /dev/null +++ b/scripts/restore-release-artifacts.mjs @@ -0,0 +1,46 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; + +const repository = "diffexai/diffex"; +let run = process.env.GITHUB_RUN_ID; +if (!/^[1-9]\d*$/.test(run ?? "")) throw new Error("Invalid artifact run ID"); +const commit = process.env.DIFFEX_SOURCE_COMMIT; +if (!/^[a-f0-9]{40}$/.test(commit ?? "")) throw new Error("Invalid resolved release commit"); +if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required"); +const api = (path) => JSON.parse(execFileSync("gh", ["api", `repos/${repository}/${path}`], { encoding: "utf8" })); +const name = `private-release-${commit}`; +function findArtifacts(id) { + const metadata = api(`actions/runs/${id}`); + if ( + metadata.path !== ".github/workflows/build-binaries.yml" || + !["push", "workflow_dispatch"].includes(metadata.event) + ) + throw new Error("Recovery artifacts must come from the release workflow"); + const inventory = api(`actions/runs/${id}/artifacts?per_page=100`); + if (inventory.total_count > 100) throw new Error("Incomplete artifact inventory; recovery refused"); + return inventory.artifacts.filter((artifact) => artifact.name === name); +} +let artifacts = findArtifacts(run); +if (!artifacts.length && process.env.RECOVERY_RUN_ID) { + if (!/^[1-9]\d*$/.test(process.env.RECOVERY_RUN_ID)) throw new Error("Invalid recovery artifact run ID"); + run = process.env.RECOVERY_RUN_ID; + artifacts = findArtifacts(run); +} +if (artifacts.length > 1 || artifacts.some((artifact) => artifact.expired)) + throw new Error("Ambiguous or expired retained artifacts; never rebuild a published version"); +if (artifacts.length === 1) { + execFileSync("gh", ["run", "download", run, "--repo", repository, "--name", name, "--dir", ".artifacts/release"], { + stdio: "inherit", + }); + appendFileSync(process.env.GITHUB_OUTPUT, `restored=true\nupload=${run !== process.env.GITHUB_RUN_ID}\n`); +} else { + // A retry without retained bytes is safe only if no publication job ever ran. + const jobs = api(`actions/runs/${run}/jobs?filter=all&per_page=100`); + if ( + process.env.RECOVERY_RUN_ID || + jobs.total_count > 100 || + jobs.jobs.some((job) => job.name === "publish-npm" && job.steps?.some((entry) => entry.status !== "queued")) + ) + throw new Error("Retained artifacts are missing; recovery cannot reconstruct published bytes"); + appendFileSync(process.env.GITHUB_OUTPUT, "restored=false\nupload=true\n"); +} diff --git a/scripts/restore-release-artifacts.test.mjs b/scripts/restore-release-artifacts.test.mjs new file mode 100644 index 000000000..52dcdd2d9 --- /dev/null +++ b/scripts/restore-release-artifacts.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const script = fileURLToPath(new URL("./restore-release-artifacts.mjs", import.meta.url)); +const commit = "a".repeat(40); +function runFixture(configuration, recovery) { + const directory = mkdtempSync(join(tmpdir(), "diffex-retained-artifacts-")); + try { + const bin = join(directory, "bin"); + mkdirSync(bin); + const config = join(directory, "fixture.json"); + writeFileSync(config, JSON.stringify(configuration)); + const calls = join(directory, "calls"); + writeFileSync(calls, ""); + const output = join(directory, "output"); + writeFileSync(join(bin, "package.json"), '{"type":"module"}'); + writeFileSync( + join(bin, "gh"), + `#!/usr/bin/env node +import {readFileSync,appendFileSync} from 'node:fs'; +const fixture=JSON.parse(readFileSync(${JSON.stringify(config)},'utf8')); +const args=process.argv.slice(2);appendFileSync(${JSON.stringify(calls)},JSON.stringify(args)+'\\n'); +if(args[0]==='run' && args[1]==='download'){if(fixture.downloadFailure)process.exit(1);process.exit(0);} +if(args[0]!=='api')throw new Error('Unmocked GitHub command'); +const match=/^repos\\/diffexai\\/diffex\\/actions\\/runs\\/(\\d+)(.*)$/.exec(args[1]);if(!match)throw new Error('Unmocked GitHub API'); +const run=fixture[match[1]];if(!run)throw new Error('Unknown run'); +if(match[2]==='')console.log(JSON.stringify({path:run.path??'.github/workflows/build-binaries.yml',event:run.event??'push'})); +else if(match[2].startsWith('/artifacts?'))console.log(JSON.stringify({artifacts:run.artifacts??[]})); +else if(match[2].startsWith('/jobs?'))console.log(JSON.stringify({total_count:run.total_count??(run.jobs??[]).length,jobs:run.jobs??[]})); +else throw new Error('Unmocked endpoint'); +`, + { mode: 0o755 }, + ); + const result = spawnSync(process.execPath, [script], { + cwd: directory, + env: { + PATH: `${bin}:${process.env.PATH}`, + GITHUB_RUN_ID: "100", + RECOVERY_RUN_ID: recovery ?? "", + DIFFEX_SOURCE_COMMIT: commit, + GITHUB_OUTPUT: output, + }, + encoding: "utf8", + timeout: 10000, + }); + return { + ...result, + output: result.status === 0 ? readFileSync(output, "utf8") : "", + calls: readFileSync(calls, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse), + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} +const artifact = { name: `private-release-${commit}`, expired: false }; +test("first build and pre-publication failure can prepare new artifacts", () => { + for (const jobs of [[], [{ name: "publish-npm", steps: [] }]]) { + const result = runFixture({ 100: { jobs } }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.output, "restored=false\nupload=true\n"); + } +}); +test("a rerun restores immutable bytes and never overwrites its artifact", () => { + const result = runFixture({ 100: { artifacts: [artifact] } }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.output, "restored=true\nupload=false\n"); + assert(result.calls.some((args) => args[0] === "run" && args[2] === "100")); +}); +test("recovery copies the original artifact once and its retry reuses the copy", () => { + const first = runFixture({ 100: {}, 90: { artifacts: [artifact] } }, "90"); + assert.equal(first.status, 0, first.stderr); + assert.equal(first.output, "restored=true\nupload=true\n"); + assert(first.calls.some((args) => args[0] === "run" && args[2] === "90")); + const retry = runFixture({ 100: { artifacts: [artifact] } }, "90"); + assert.equal(retry.status, 0, retry.stderr); + assert.equal(retry.output, "restored=true\nupload=false\n"); +}); +for (const [name, configuration, recovery] of [ + [ + "missing evidence after npm started", + { 100: { jobs: [{ name: "publish-npm", steps: [{ status: "completed" }] }] } }, + undefined, + ], + ["missing recovery evidence", { 100: {}, 90: {} }, "90"], + ["expired evidence", { 100: { artifacts: [{ ...artifact, expired: true }] } }, undefined], + ["ambiguous evidence", { 100: { artifacts: [artifact, artifact] } }, undefined], + ["foreign workflow", { 100: {}, 90: { path: ".github/workflows/ci.yml", artifacts: [artifact] } }, "90"], + ["untrusted pull request artifacts", { 100: { event: "pull_request", artifacts: [artifact] } }, undefined], + ["failed artifact download", { 100: { artifacts: [artifact] }, downloadFailure: true }, undefined], + ["incomplete publication history", { 100: { total_count: 101 } }, undefined], +]) + test(`${name} blocks recovery instead of rebuilding`, () => { + const result = runFixture(configuration, recovery); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.output, ""); + });