diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26bea6adb..bce48c989 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,9 +78,9 @@ jobs: if: ${{ inputs.operation == 'prepare-release-pr' }} environment: release-pr permissions: - contents: write - issues: write - pull-requests: write + contents: read + issues: read + pull-requests: read steps: - name: Require main run: | @@ -97,15 +97,17 @@ jobs: id: require_current_main timeout-minutes: 1 run: bash .github/scripts/require-current-main.sh "$GITHUB_SHA" - - name: Require release PR token - env: - RELEASE_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} - run: | - if [[ -z "${RELEASE_PR_TOKEN}" ]]; then - echo "RELEASE_PR_TOKEN is required so generated release PRs trigger normal PR CI." >&2 - echo "Configure a GitHub App or maintainer bot token in the release-pr environment." >&2 - exit 1 - fi + - name: Create Oli release PR token + id: release_pr_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + owner: f0rr0 + repositories: oliphaunt + permission-contents: write + permission-issues: write + permission-pull-requests: write - name: Set up Moon uses: ./.github/actions/setup-moon with: @@ -114,7 +116,7 @@ jobs: - name: Generate the Release Please candidate locally id: prepare_candidate env: - GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} + GH_TOKEN: ${{ steps.release_pr_token.outputs.token }} run: bash tools/release/prepare-release-pr.sh "$RUNNER_TEMP/release-candidate" - name: Set up Rust for release manifest synchronization if: ${{ steps.prepare_candidate.outputs.required == 'true' }} @@ -125,7 +127,7 @@ jobs: - name: Publish the completed release PR tree if: ${{ steps.prepare_candidate.outputs.required == 'true' }} env: - GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} + GH_TOKEN: ${{ steps.release_pr_token.outputs.token }} run: bash .github/scripts/publish-release-pr.sh "$RUNNER_TEMP/release-candidate" - name: Report no release changes if: ${{ steps.prepare_candidate.outputs.required != 'true' }} @@ -885,7 +887,7 @@ jobs: environment: release-bootstrap permissions: actions: read - contents: write + contents: read id-token: write pull-requests: read steps: @@ -937,9 +939,18 @@ jobs: uses: ./.github/actions/setup-npm-publisher with: npm-version: ${{ env.NPM_VERSION }} + - name: Create Oli bootstrap preflight token + id: bootstrap_preflight_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + owner: f0rr0 + repositories: oliphaunt + permission-contents: write - name: Preflight selected product tag and release collisions env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.bootstrap_preflight_token.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: | bash tools/release/verify-product-tags.sh \ @@ -1187,11 +1198,9 @@ jobs: promoted: ${{ steps.promote_github_releases.outcome == 'success' }} permissions: actions: read - artifact-metadata: write - attestations: write - contents: write + contents: read id-token: write - pull-requests: write + pull-requests: read steps: - name: Record release deadline id: release_job_deadline @@ -1237,11 +1246,25 @@ jobs: env: RELEASE_OPERATION: publish run: bun .github/scripts/verify-github-oidc-identity.mts + - name: Check Oli release App permissions + id: check_release_tag_app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + owner: f0rr0 + repositories: oliphaunt + permission-contents: write + permission-workflows: write + permission-issues: write + permission-pull-requests: write + permission-attestations: write + permission-actions: read - name: Prove Release Please PR can complete after publication id: assert_release_please_markable timeout-minutes: 1 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.check_release_tag_app.outputs.token }} run: | tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mts \ assert-markable \ @@ -1249,7 +1272,7 @@ jobs: --base main - name: Preflight selected product tag and release collisions env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.check_release_tag_app.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: | gh auth setup-git @@ -1260,14 +1283,10 @@ jobs: bun .github/scripts/manage-release-drafts.mts preflight \ --products-json "$PRODUCTS_JSON" \ --head-ref "$RELEASE_HEAD_SHA" - - name: Check release tag App permissions - id: check_release_tag_app - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 - with: *release_tag_app - name: Check publish environment env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.check_release_tag_app.outputs.token }} + GITHUB_TOKEN: ${{ steps.check_release_tag_app.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} @@ -1316,8 +1335,8 @@ jobs: - name: Validate product versions and registry state id: validate_release_registry_state env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.check_release_tag_app.outputs.token }} + GITHUB_TOKEN: ${{ steps.check_release_tag_app.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: | bash tools/release/release-check-registries.sh \ @@ -1423,14 +1442,14 @@ jobs: id: verify_product_tags timeout-minutes: 5 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release_tag_token.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: bash tools/release/verify-product-tags.sh --products-json "${PRODUCTS_JSON}" --target "$RELEASE_HEAD_SHA" - name: Verify exact-SHA GitHub release staging id: verify_github_staging timeout-minutes: 5 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release_tag_token.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: bun .github/scripts/manage-release-drafts.mts verify --products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_HEAD_SHA" --state staged - name: Publish all selected GitHub release asset sets concurrently @@ -1439,6 +1458,9 @@ jobs: env: GITHUB_RELEASE_ASSET_UPLOAD_REPORT_PATH: ${{ runner.temp }}/oliphaunt-github-release-asset-upload-report.json GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG_APP_CLIENT_ID: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + RELEASE_TAG_APP_PRIVATE_KEY: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + RELEASE_APP_INSTALLATION_ID: ${{ steps.check_release_tag_app.outputs.installation-id }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: bash tools/release/with-source.sh "$RELEASE_HEAD_SHA" bash tools/dev/bun.sh tools/release/release-publish.mts publish --step github-release-assets --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" --publication-lock "$PUBLICATION_LOCK_PATH" - name: Resolve frozen release provenance subjects @@ -1455,6 +1477,17 @@ jobs: run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mts reserve --label "release asset provenance" tools/dev/bun.sh tools/release/github-core-request-journal.mts reserve --label "release asset provenance API attempt" + - name: Create Oli attestation token + id: release_attestation_token + if: ${{ steps.release_provenance_subjects.outputs.has_subjects == 'true' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + owner: f0rr0 + repositories: oliphaunt + permission-contents: read + permission-attestations: write - name: Attest frozen release assets id: release_provenance if: ${{ steps.release_provenance_subjects.outputs.has_subjects == 'true' }} @@ -1462,6 +1495,7 @@ jobs: uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: subject-path: ${{ steps.release_provenance_subjects.outputs.subject_paths }} + github-token: ${{ steps.release_attestation_token.outputs.token }} - name: Create fresh SwiftPM tag token id: swift_tag_token if: ${{ contains(fromJson(fromJSON(needs.plan-candidate.outputs.release_plan).products_json), 'oliphaunt-swift') }} @@ -1474,12 +1508,23 @@ jobs: env: GH_TOKEN: ${{ steps.swift_tag_token.outputs.token }} run: bash tools/release/publish-swiftpm-source-tag.sh --push --target "$RELEASE_HEAD_SHA" --publication-lock "$PUBLICATION_LOCK_PATH" + - name: Create fresh Oli verification token + id: release_evidence_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: &release_evidence_app + client-id: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + owner: f0rr0 + repositories: oliphaunt + permission-contents: write + permission-actions: read + permission-attestations: read - name: Freeze exact GitHub release asset and attestation evidence id: freeze_github_evidence timeout-minutes: 10 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release_evidence_token.outputs.token }} + GITHUB_TOKEN: ${{ steps.release_evidence_token.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} RELEASE_PROVENANCE_BUNDLE: ${{ steps.release_provenance.outputs.bundle-path }} run: | @@ -1521,12 +1566,16 @@ jobs: --products-json "$PRODUCTS_JSON" \ --head-ref "$RELEASE_HEAD_SHA" \ --publication-lock "$PUBLICATION_LOCK_PATH" + - name: Create fresh Oli verification token + id: release_verify_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: *release_evidence_app - name: Verify published release id: verify_published_release timeout-minutes: 8 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release_verify_token.outputs.token }} + GITHUB_TOKEN: ${{ steps.release_verify_token.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: | gh auth setup-git @@ -1585,11 +1634,22 @@ jobs: include-hidden-files: true overwrite: true retention-days: 90 + - name: Create Oli release promotion token + id: release_promotion_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ secrets.RELEASE_TAG_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_TAG_APP_PRIVATE_KEY }} + owner: f0rr0 + repositories: oliphaunt + permission-contents: write + permission-issues: write + permission-pull-requests: write - name: Promote verified GitHub release drafts id: promote_github_releases timeout-minutes: 16 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release_promotion_token.outputs.token }} PRODUCTS_JSON: ${{ fromJSON(needs.plan-candidate.outputs.release_plan).products_json }} run: | tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mts \ diff --git a/src/docs/maintainers/release-setup.md b/src/docs/maintainers/release-setup.md index 48e5fb8a6..33fd1e2bf 100644 --- a/src/docs/maintainers/release-setup.md +++ b/src/docs/maintainers/release-setup.md @@ -30,33 +30,60 @@ Create these environments: | Environment | Purpose | Secrets | Protection | | --- | --- | --- | --- | -| `release-pr` | Create/update the generated release PR | `RELEASE_PR_TOKEN` | main only | +| `release-pr` | Create/update the generated release PR | Release App credentials | main only | | `release-dry-run` | Exact-SHA artifact assembly and dry-run | none | main only | -| `release-bootstrap` | First npm/crates publication and required unpublished dependency versions | Release tag App credentials plus only the short-lived, registry-scoped `CRATES_IO_BOOTSTRAP_TOKEN` and/or `NPM_BOOTSTRAP_TOKEN` required by the approved lock | `main` only; independent approval when available | -| `release-publish` | Normal trusted publication | Release tag App credentials, Maven Central credentials and signing key | `main` only; independent approval when available | - -Use a GitHub App or narrowly scoped bot token for `RELEASE_PR_TOKEN`; PRs created by the default workflow token do not trigger the normal PR workflow. Keep bootstrap tokens out of repository secrets and out of `release-publish`. The approved-candidate inventory determines which of the two bootstrap tokens is required; do not provision a Cargo token for an npm-only bootstrap or vice versa. Delete/revoke each token immediately after trusted publishers are configured. - -Create one private GitHub App installed only on `f0rr0/oliphaunt`, with -repository **Contents: read and write** and **Workflows: read and write** (no -webhooks or account permissions). Store `RELEASE_TAG_APP_CLIENT_ID` and -`RELEASE_TAG_APP_PRIVATE_KEY` in both `release-publish` and `release-bootstrap`. -These are GitHub tag credentials; retain them when retiring registry bootstrap -tokens. Do not copy `RELEASE_PR_TOKEN` or use a personal token for this role. +| `release-bootstrap` | First npm/crates publication and required unpublished dependency versions | Release App credentials plus only the short-lived, registry-scoped `CRATES_IO_BOOTSTRAP_TOKEN` and/or `NPM_BOOTSTRAP_TOKEN` required by the approved lock | `main` only; independent approval when available | +| `release-publish` | Normal trusted publication | Release App credentials, Maven Central credentials and signing key | `main` only; independent approval when available | + +Use the existing private GitHub App for all release mutations, with the friendly +display name **Oli Release Bot**. GitHub reserves existing account names, so +the short name `Oli` cannot be used. Its installation remains limited to +`f0rr0/oliphaunt`, without webhooks or account/organization permissions. Grant +repository **Contents**, **Workflows**, **Issues**, **Pull requests**, and +**Attestations** read and write, plus **Actions** read-only. Each workflow phase +requests only its own subset of these permissions. PR creation uses an App token +so normal PR CI runs automatically; the default workflow token requires approval +for workflow-created PR runs. + +Keep `RELEASE_TAG_APP_CLIENT_ID` and `RELEASE_TAG_APP_PRIVATE_KEY` in each of +`release-pr`, `release-publish`, and `release-bootstrap`. These existing secret +names now cover the complete release App. Retain them when retiring registry +bootstrap tokens. The legacy `RELEASE_PR_TOKEN` is no longer used. + +To migrate an existing installation, rename the existing App in GitHub's +Developer settings, add the permissions above, and accept the installation's +permission update. Add the same App credentials to the protected `release-pr` +environment. GitHub cannot return encrypted secret values for copying, so use +the original private key or provision a replacement key in all three environments. +After the migrated preparation job creates a PR and its CI starts automatically, +remove/revoke the obsolete `RELEASE_PR_TOKEN`. Do not recreate the App or rewrite +existing release history to change its name. + +Keep bootstrap tokens out of repository secrets and out of `release-publish`. +The approved-candidate inventory determines which of the two bootstrap tokens +is required; do not provision a Cargo token for an npm-only bootstrap or vice +versa. Delete/revoke each token immediately after trusted publishers are configured. GitHub can reject tags on an older candidate whose workflow files differ from the publishing commit, even with an existing transport ref. The normal `GITHUB_TOKEN` cannot request `workflows: write`. The pinned -`actions/create-github-app-token` action requests only the two required -permissions for this repository, checks them before publication, and -mints fresh tokens immediately before product/transport tags and SwiftPM tags. -It revokes each installation token during job cleanup. Ordinary publication, -attestations, and registry authentication keep their existing credentials. +`actions/create-github-app-token` action requests explicit phase permissions +for this repository, checks the complete App permissions before publication, +and mints fresh tokens before tags, attestations, evidence checks, and promotion. +It revokes its installation tokens during job cleanup. The concurrent asset +uploader uses `release-app-token.mts` to mint repository-scoped, Contents-only +tokens and share a single refresh across its lanes five minutes before expiry. +It revokes all still-live tokens after every lane has drained, including on failure. +Draft inventories use the same renewable App credentials as uploads so read-only +workflow tokens cannot hide drafts. Registry authentication retains workflow OIDC. A missing App installation or permission fails before release mutations. -The App's bot account is `oliphaunt-release-bot[bot]` (GitHub user ID -`326451763`). `tools/release/release-bot.json` owns its commit name and linked -noreply address for generated release helper commits. SwiftPM commit authorship +The existing App's bot account is `oli-release-bot[bot]` (GitHub user ID +`326451763`). `tools/release/release-bot.json` uses **Oli [bot]** as the commit +display name and uses the renamed bot's linked noreply address. If renaming the +App changes its bot login, update that address from the installation's actual bot login and +the same numeric user ID; do not invent a separate `oli[bot]` account. +SwiftPM commit authorship is read from the candidate's copy of that file so newer publisher code cannot change an older approved synthetic commit; candidates predating the file retain the legacy identity. Do not rewrite existing release history to change avatars. @@ -152,13 +179,16 @@ Each credential-bearing job directly selects exactly one protected environment: `release-pr`, `release-bootstrap`, or `release-publish`. Keep the named secrets only in those environments; do not add repository-level duplicates or a reusable-workflow secret bridge. GitHub automatically provides the scoped -`GITHUB_TOKEN`. Every job declares its own effective permissions: dry-run is -read-only, bootstrap adds `contents: write`, -preparation gets release-PR writes, and normal publish runs in one direct job -with the `release-publish` grants. Bootstrap's -content write is solely to create the immutable -release transport tag immediately before its first registry mutation; -reruns do not create, move, or delete repository refs. +`GITHUB_TOKEN`. Release PR, bootstrap, and publication jobs keep the built-in +token's repository mutation permissions read-only. The qualification request +job retains `actions: write` to dispatch CI; publication and bootstrap retain +`id-token: write` for registry/Sigstore OIDC. +Oli performs release PR, tag, asset, attestation, and promotion mutations with +phase-scoped App tokens. Bootstrap's App content write is solely to create the +immutable release transport tag immediately before its first registry mutation. +Its draft-collision preflight also uses an App token with Contents write so draft +releases remain visible; that check performs only reads. Reruns do not create, +move, or delete repository refs. Candidate preparation, conditional bootstrap, and publication are dependent jobs in one `publish` run with separate permissions and environments. The read-only dry-run validates every public release visible to its token and diff --git a/src/docs/maintainers/release.md b/src/docs/maintainers/release.md index 55f11d60b..86471397f 100644 --- a/src/docs/maintainers/release.md +++ b/src/docs/maintainers/release.md @@ -269,9 +269,11 @@ those names and revoke bootstrap tokens before the next release. Credential-bearing jobs directly select `release-pr`, `release-bootstrap`, or `release-publish`. Keep secrets in those protected environments. The -bootstrap job's content-write permission creates the immutable transport tag; -normal publication owns GitHub staging, attestations, and promotion. GitHub -provides each job's scoped `GITHUB_TOKEN`. +Oli App's phase-scoped content-write token creates the bootstrap transport tag; +the same App owns release PRs, GitHub staging, asset uploads, attestations, and +promotion. Long asset uploads renew the App credential before its one-hour +expiry and revoke issued tokens after the lanes drain. GitHub provides each +job's read-only repository `GITHUB_TOKEN` and workflow OIDC credentials. Trusted publishers match `release.yml`: direct publication exposes that file through `workflow_ref`, together with the exact `workflow_sha` and the diff --git a/tools/release/audit-github-release-controls.mts b/tools/release/audit-github-release-controls.mts index cc5af2c4a..380892e36 100644 --- a/tools/release/audit-github-release-controls.mts +++ b/tools/release/audit-github-release-controls.mts @@ -79,7 +79,7 @@ function expectedEnvironments(bootstrapState) { return { 'release-bootstrap': [...RELEASE_TAG_APP_SECRETS, ...expectedBootstrapSecrets(bootstrapState)], 'release-dry-run': [], - 'release-pr': ['RELEASE_PR_TOKEN'], + 'release-pr': RELEASE_TAG_APP_SECRETS, 'release-publish': RELEASE_PUBLISH_SECRETS, }; } diff --git a/tools/release/fixtures/github-release-controls/desired-solo.json b/tools/release/fixtures/github-release-controls/desired-solo.json index 5e2fadf7a..58592637b 100644 --- a/tools/release/fixtures/github-release-controls/desired-solo.json +++ b/tools/release/fixtures/github-release-controls/desired-solo.json @@ -59,7 +59,7 @@ } }, "branchPolicies": [{ "name": "main", "type": "branch" }], - "secretNames": ["RELEASE_PR_TOKEN"] + "secretNames": ["RELEASE_TAG_APP_CLIENT_ID", "RELEASE_TAG_APP_PRIVATE_KEY"] }, "release-dry-run": { "environment": { diff --git a/tools/release/fixtures/github-release-controls/desired-team.json b/tools/release/fixtures/github-release-controls/desired-team.json index e67067c50..be5baa7ce 100644 --- a/tools/release/fixtures/github-release-controls/desired-team.json +++ b/tools/release/fixtures/github-release-controls/desired-team.json @@ -59,7 +59,7 @@ } }, "branchPolicies": [{ "name": "main", "type": "branch" }], - "secretNames": ["RELEASE_PR_TOKEN"] + "secretNames": ["RELEASE_TAG_APP_CLIENT_ID", "RELEASE_TAG_APP_PRIVATE_KEY"] }, "release-dry-run": { "environment": { diff --git a/tools/release/github-read.mts b/tools/release/github-read.mts index 6994adf2e..f6345958f 100644 --- a/tools/release/github-read.mts +++ b/tools/release/github-read.mts @@ -556,7 +556,7 @@ export async function requestGithubPages(endpoint, options = {}) { const { data, link } = await requestGithubJsonWithRetry( 'https://api.github.com/' + pageEndpoint, { - authToken: environment.GH_TOKEN || environment.GITHUB_TOKEN || '', + authToken: options.getToken ?? (environment.GH_TOKEN || environment.GITHUB_TOKEN || ''), coreJournalOptions: { environment, ...options.coreJournalOptions }, deadlineMs: paginationDeadlineMs, fetchImpl: options.fetchImpl, @@ -608,7 +608,7 @@ export async function requestGithubRepositoryJson(endpoint, options = {}) { return await requestGithubJsonWithRetry( endpoint.startsWith('https:') ? endpoint : 'https://api.github.com/' + endpoint, { - authToken: environment.GH_TOKEN || environment.GITHUB_TOKEN || '', + authToken: options.getToken ?? (environment.GH_TOKEN || environment.GITHUB_TOKEN || ''), coreJournalOptions: { environment, ...options.coreJournalOptions }, deadlineMs: Math.min( now() + settings.deadlineMs, @@ -848,6 +848,7 @@ export async function requestGithubJsonWithRetry( ...(coreJournalOptions ?? {}), label: `GitHub release JSON ${new URL(url).pathname}`, }); + const token = typeof authToken === 'function' ? await authToken() : authToken; const transportRemaining = effectiveDeadline - nowImpl(); if (transportRemaining <= 0) { throw new Error( @@ -856,7 +857,7 @@ export async function requestGithubJsonWithRetry( } try { response = await fetchImpl(url, { - headers: authHeaders('application/vnd.github+json', authToken), + headers: authHeaders('application/vnd.github+json', token), redirect: 'error', signal: AbortSignal.timeout(Math.max(1, Math.min(attemptTimeoutMs, transportRemaining))), }); diff --git a/tools/release/github-release-mutations.mts b/tools/release/github-release-mutations.mts index c8e47d01c..6200f9f31 100644 --- a/tools/release/github-release-mutations.mts +++ b/tools/release/github-release-mutations.mts @@ -698,6 +698,9 @@ export async function requestGithubMutation(endpoint, options = {}) { ) { throw mutationError('GitHub mutation abort guard must be a function'); } + if (options.getToken !== undefined && typeof options.getToken !== 'function') { + throw mutationError('GitHub mutation token provider must be a function'); + } if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0) { throw mutationError('GitHub mutation timeout must be a positive integer'); } @@ -733,6 +736,9 @@ export async function requestGithubMutation(endpoint, options = {}) { label: options.coreRequestLabel ?? 'GitHub release mutation', ...(options.coreJournalOptions ?? {}), }); + const token = options.getToken + ? await options.getToken() + : environment.GH_TOKEN || environment.GITHUB_TOKEN || ''; // Recheck after the independently locked request journal so a peer failure // observed while this worker waited cannot leak a new mutation transport. options.assertMutationAllowed?.(); @@ -747,10 +753,7 @@ export async function requestGithubMutation(endpoint, options = {}) { } const upload = endpoint.startsWith('https://uploads.github.com/'); const url = upload ? endpoint : 'https://api.github.com/' + endpoint; - const headers = authHeaders( - 'application/vnd.github+json', - environment.GH_TOKEN || environment.GITHUB_TOKEN || '', - ); + const headers = authHeaders('application/vnd.github+json', token); let body = options.input; if (upload) { const file = options.file; @@ -782,7 +785,7 @@ export async function requestGithubMutation(endpoint, options = {}) { if (!response.ok) { const error = mutationError(`GitHub mutation returned HTTP ${response.status}`); error.httpStatus = response.status; - error.detail = redactGitHubReadDetail(text, environment); + error.detail = redactGitHubReadDetail(text, { ...environment, GH_TOKEN: token }); throw error; } return text; diff --git a/tools/release/publication-controller.mts b/tools/release/publication-controller.mts index 7752dd012..f7c0b1198 100644 --- a/tools/release/publication-controller.mts +++ b/tools/release/publication-controller.mts @@ -26,6 +26,10 @@ const CONTROL_FILES = new Set([ 'tools/release/crates-io-bootstrap-capacity.mts', 'tools/release/bootstrap-registry-reconciliation.mts', 'tools/release/release-publish.mts', + 'tools/release/release-app-token.mts', + 'tools/release/github-release-mutations.mts', + 'tools/release/github-read.mts', + 'tools/release/upload_github_release_assets.mts', 'tools/release/publish-registries.sh', 'tools/release/publish-frozen-npm.sh', 'tools/release/trusted-publisher-config.sh', diff --git a/tools/release/release-app-token.mts b/tools/release/release-app-token.mts new file mode 100644 index 000000000..e9827e1db --- /dev/null +++ b/tools/release/release-app-token.mts @@ -0,0 +1,127 @@ +import { createPrivateKey, sign } from 'node:crypto'; +import { reserveGitHubCoreRequest } from './github-core-request-journal.mts'; +import { authHeaders, boundedResponseBytes } from './github-read.mts'; + +const REFRESH_MARGIN_MS = 5 * 60_000; + +// Uploads can span several one-hour installation tokens. Keep a single shared +// refresh in flight, and revoke issued tokens only after all upload lanes drain. +export function createReleaseAppToken({ + environment = process.env, + fetchImpl = fetch, + now = Date.now, + mask = (token) => { + if (environment.GITHUB_ACTIONS === 'true') console.log(`::add-mask::${token}`); + }, +} = {}) { + if (environment.GITHUB_REPOSITORY !== 'f0rr0/oliphaunt') + throw new Error('Oli upload credentials require the canonical release repository'); + const clientId = environment.RELEASE_TAG_APP_CLIENT_ID; + const installationId = environment.RELEASE_APP_INSTALLATION_ID; + if (!/^[A-Za-z0-9_.-]+$/u.test(clientId ?? '') || !/^[1-9][0-9]*$/u.test(installationId ?? '')) + throw new Error('Oli upload credentials require the App client ID and installation ID'); + let key; + try { + key = createPrivateKey((environment.RELEASE_TAG_APP_PRIVATE_KEY ?? '').replace(/\\n/gu, '\n')); + if (key.asymmetricKeyType !== 'rsa') throw new Error('not RSA'); + } catch { + throw new Error('Oli upload credentials require an RSA App private key'); + } + const issued = new Map(); + let cached; + let pending; + let closed = false; + + async function request(endpoint, credential, method, input) { + await reserveGitHubCoreRequest({ environment, label: `Oli App token ${method}` }); + let response; + try { + response = await fetchImpl(`https://api.github.com/${endpoint}`, { + method, + headers: { + ...authHeaders('application/vnd.github+json', credential), + 'Content-Type': 'application/json', + }, + body: input === undefined ? undefined : JSON.stringify(input), + redirect: 'error', + signal: AbortSignal.timeout(20_000), + }); + } catch { + throw new Error(`Oli App token ${method} transport failed`); + } + if (!response.ok) { + await response.body?.cancel?.(); + throw new Error(`Oli App token ${method} returned HTTP ${response.status}`); + } + return response; + } + + async function refresh() { + const seconds = Math.floor(now() / 1_000); + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const payload = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ + iat: seconds - 60, + exp: seconds + 540, + iss: clientId, + })}`; + const jwt = `${payload}.${sign('RSA-SHA256', Buffer.from(payload), key).toString('base64url')}`; + const response = await request( + `app/installations/${installationId}/access_tokens`, + jwt, + 'POST', + { repositories: ['oliphaunt'], permissions: { contents: 'write' } }, + ); + let data; + try { + const bytes = await boundedResponseBytes(response, 32 * 1024, 'Oli App token response'); + data = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new Error('Oli App token response is malformed'); + } + if (typeof data?.token !== 'string' || !/^[A-Za-z0-9_.=-]{1,8192}$/u.test(data.token)) + throw new Error('Oli App token response has no valid installation token'); + mask(data.token); + const expiresAt = Date.parse(data.expires_at); + issued.set(data.token, expiresAt); + if ( + !Number.isFinite(expiresAt) || + expiresAt <= now() + REFRESH_MARGIN_MS || + data.permissions?.contents !== 'write' || + Object.entries(data.permissions).some( + ([permission, access]) => + permission !== 'contents' && !(permission === 'metadata' && access === 'read'), + ) + ) + throw new Error('Oli App upload token has invalid expiry or permissions'); + cached = { token: data.token, expiresAt }; + return cached.token; + } + + return { + async token() { + if (closed) throw new Error('Oli App upload credentials are closed'); + if (cached?.expiresAt > now() + REFRESH_MARGIN_MS) return cached.token; + pending ??= refresh().finally(() => { + pending = undefined; + }); + return await pending; + }, + async close() { + closed = true; + await pending?.catch(() => {}); + const failures = []; + for (const [token, expiresAt] of issued) { + if (expiresAt <= now()) continue; + try { + const response = await request('installation/token', token, 'DELETE'); + await response.body?.cancel?.(); + } catch (cause) { + failures.push(cause.message); + } + } + issued.clear(); + cached = undefined; + if (failures.length) throw new Error(`Oli App token cleanup failed: ${failures.join('; ')}`); + }, + }; +} diff --git a/tools/release/release-app-token.test.mts b/tools/release/release-app-token.test.mts new file mode 100644 index 000000000..ff8663854 --- /dev/null +++ b/tools/release/release-app-token.test.mts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { createReleaseAppToken } from './release-app-token.mts'; +import { requestGithubMutation } from './github-release-mutations.mts'; +import { requestGithubPages, requestGithubRepositoryJson } from './github-read.mts'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const environment = { + GITHUB_REPOSITORY: 'f0rr0/oliphaunt', + RELEASE_TAG_APP_CLIENT_ID: 'Iv1.fixture', + RELEASE_TAG_APP_PRIVATE_KEY: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + RELEASE_APP_INSTALLATION_ID: '123', +}; + +test('concurrent upload lanes share refreshes, rotate before expiry, and revoke after draining', async () => { + let clock = Date.parse('2026-10-05T00:00:00Z'); + const masked = []; + const revoked = []; + let created = 0; + const credentials = createReleaseAppToken({ + environment, + now: () => clock, + mask: (token) => masked.push(token), + fetchImpl: async (url, init) => { + assert.equal(init.redirect, 'error'); + assert.ok(init.signal instanceof AbortSignal); + if (init.method === 'DELETE') { + assert.equal(url, 'https://api.github.com/installation/token'); + revoked.push(init.headers.Authorization); + return new Response(null, { status: 204 }); + } + assert.equal(url, 'https://api.github.com/app/installations/123/access_tokens'); + assert.deepEqual(JSON.parse(init.body), { + repositories: ['oliphaunt'], + permissions: { contents: 'write' }, + }); + const jwt = init.headers.Authorization.slice('Bearer '.length); + const [header, payload, signature] = jwt.split('.'); + assert.equal( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, 'base64url'), + ), + true, + ); + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString()); + assert.equal(claims.iss, environment.RELEASE_TAG_APP_CLIENT_ID); + assert.equal(claims.iat, Math.floor(clock / 1000) - 60); + assert.equal(claims.exp, Math.floor(clock / 1000) + 540); + created += 1; + await Promise.resolve(); + return Response.json({ + token: `fixture-${created}`, + expires_at: new Date(clock + 60 * 60_000).toISOString(), + permissions: { contents: 'write', metadata: 'read' }, + }); + }, + }); + assert.deepEqual( + await Promise.all(Array.from({ length: 5 }, () => credentials.token())), + Array(5).fill('fixture-1'), + ); + clock += 54 * 60_000; + assert.equal(await credentials.token(), 'fixture-1'); + clock += 60_000; + assert.deepEqual( + await Promise.all(Array.from({ length: 5 }, () => credentials.token())), + Array(5).fill('fixture-2'), + ); + assert.equal(created, 2); + assert.deepEqual(masked, ['fixture-1', 'fixture-2']); + assert.deepEqual(revoked, []); + await credentials.close(); + assert.deepEqual(revoked, ['Bearer fixture-1', 'Bearer fixture-2']); + await assert.rejects(credentials.token(), /closed/u); +}); + +test('invalid or unavailable App credentials fail without falling back to the Actions token', async () => { + assert.throws( + () => + createReleaseAppToken({ environment: { ...environment, GITHUB_REPOSITORY: 'other/repo' } }), + /canonical/u, + ); + assert.throws( + () => + createReleaseAppToken({ environment: { ...environment, RELEASE_TAG_APP_PRIVATE_KEY: '' } }), + /private key/u, + ); + let writes = 0; + const credentials = createReleaseAppToken({ + environment: { ...environment, GH_TOKEN: 'native-token' }, + mask: () => {}, + fetchImpl: () => new Response('private response', { status: 403 }), + }); + await assert.rejects( + requestGithubMutation('repos/o/r/git/refs', { + environment: { GH_TOKEN: 'native-token' }, + getToken: () => credentials.token(), + method: 'POST', + input: JSON.stringify({ ref: 'refs/tags/v1', sha: 'a'.repeat(40) }), + timeoutMs: 1000, + fetchImpl: () => { + writes += 1; + return Response.json({}); + }, + }), + /HTTP 403/u, + ); + assert.equal(writes, 0); + await credentials.close(); +}); + +test('unexpected token permissions fail closed and still revoke the returned credential', async () => { + const calls = []; + const credentials = createReleaseAppToken({ + environment, + mask: () => {}, + fetchImpl: (url, init) => { + calls.push(init.method); + return init.method === 'DELETE' + ? new Response(null, { status: 204 }) + : Response.json({ + token: 'fixture-token', + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + permissions: { contents: 'write', workflows: 'write' }, + }); + }, + }); + await assert.rejects(credentials.token(), /invalid expiry or permissions/u); + await credentials.close(); + assert.deepEqual(calls, ['POST', 'DELETE']); +}); + +test('mutation transports use the renewable App credential and redact it from errors', async () => { + let calls = 0; + await assert.rejects( + requestGithubMutation('repos/o/r/git/refs', { + environment: { GH_TOKEN: 'native-token' }, + getToken: async () => 'oli-private-token', + method: 'POST', + input: JSON.stringify({ ref: 'refs/tags/v1', sha: 'a'.repeat(40) }), + timeoutMs: 1000, + fetchImpl: (url, init) => { + calls += 1; + assert.equal(init.headers.Authorization, 'Bearer oli-private-token'); + return new Response('oli-private-token', { status: 503 }); + }, + }), + (cause) => !cause.detail.includes('oli-private-token') && cause.detail.includes(''), + ); + assert.equal(calls, 1, 'a token change must never replay a release mutation'); +}); + +test('release workflow reserves repository mutations for Oli with per-step token scopes', () => { + const workflow = Bun.YAML.parse( + readFileSync(new URL('../../.github/workflows/release.yml', import.meta.url), 'utf8'), + ); + for (const name of ['prepare-release-pr', 'publish-bootstrap', 'publish']) { + for (const scope of [ + 'contents', + 'issues', + 'pull-requests', + 'attestations', + 'artifact-metadata', + ]) + assert.notEqual(workflow.jobs[name].permissions[scope], 'write', `${name} ${scope}`); + } + const steps = new Map(workflow.jobs.publish.steps.map((step) => [step.id ?? step.name, step])); + assert.equal( + steps.get('promote_github_releases').env.GH_TOKEN, + '${{ steps.release_promotion_token.outputs.token }}', + ); + assert.equal( + steps.get('release_provenance').with['github-token'], + '${{ steps.release_attestation_token.outputs.token }}', + ); + assert.equal( + steps.get('publish_github_assets').env.RELEASE_APP_INSTALLATION_ID, + '${{ steps.check_release_tag_app.outputs.installation-id }}', + ); + assert.equal(steps.get('release_attestation_token').with['permission-attestations'], 'write'); + const pr = workflow.jobs['prepare-release-pr']; + assert.equal(pr.environment, 'release-pr'); + for (const step of pr.steps.filter((step) => step.env?.GH_TOKEN)) + assert.equal(step.env.GH_TOKEN, '${{ steps.release_pr_token.outputs.token }}'); + for (const name of ['publish-bootstrap', 'publish']) { + const preflight = workflow.jobs[name].steps.find( + (step) => step.name === 'Preflight selected product tag and release collisions', + ); + assert.match(preflight.env.GH_TOKEN, /steps\.[a-z_]+\.outputs\.token/u); + const tokenId = preflight.env.GH_TOKEN.match(/steps\.([a-z_]+)\.outputs/u)[1]; + const tokenStep = workflow.jobs[name].steps.find((step) => step.id === tokenId); + assert.equal(tokenStep.with['permission-contents'], 'write', 'draft collision visibility'); + } + assert.equal(workflow.jobs['request-qualification'].permissions.actions, 'write'); + assert.equal(workflow.jobs['publish-bootstrap'].permissions['id-token'], 'write'); + assert.equal(workflow.jobs.publish.permissions['id-token'], 'write'); +}); + +test('draft inventories resolve fresh App credentials for repository and paginated reads', async () => { + let generation = 0; + const options = { + environment: { GH_TOKEN: 'native-token' }, + getToken: async () => `oli-${++generation}`, + fetchImpl: (url, init) => { + assert.equal(init.headers.Authorization, `Bearer oli-${generation}`); + return Response.json([]); + }, + }; + await requestGithubRepositoryJson('repos/o/r/releases/42', options); + await requestGithubPages('repos/o/r/releases', options); + assert.equal(generation, 2); +}); diff --git a/tools/release/release-bot.json b/tools/release/release-bot.json index bd656bcb3..0ba491978 100644 --- a/tools/release/release-bot.json +++ b/tools/release/release-bot.json @@ -1,4 +1,4 @@ { - "name": "oliphaunt-release-bot[bot]", - "email": "326451763+oliphaunt-release-bot[bot]@users.noreply.github.com" + "name": "Oli [bot]", + "email": "326451763+oli-release-bot[bot]@users.noreply.github.com" } diff --git a/tools/release/release-publish.mts b/tools/release/release-publish.mts index 81449abef..8b4e025f8 100755 --- a/tools/release/release-publish.mts +++ b/tools/release/release-publish.mts @@ -62,6 +62,7 @@ import { compareText, currentProductVersionSync } from './release-artifact-targe import { ROOT, uniqueValueFlag } from './release-cli-utils.mts'; import { loadProducts, releaseOrder } from './release-graph.mts'; import { frozenUploadPlan, uploadFrozenReleaseAssets } from './upload_github_release_assets.mts'; +import { createReleaseAppToken } from './release-app-token.mts'; const TOOL = 'release-publish.mts'; const REGISTRY_DEADLINE_RESERVE_MS = 5_000; @@ -355,12 +356,15 @@ async function publishSelectedGithubReleaseAssetSets(products, headRef, { check ); if (check) return; await verifyReleaseTags(selected, headRef); + const credentials = createReleaseAppToken(); const coordinationRoot = mkdtempSync(path.join(tmpdir(), 'oliphaunt-github-release-asset-wave-')); const abortPath = path.join(coordinationRoot, 'abort.json'); const reportPath = process.env.GITHUB_RELEASE_ASSET_UPLOAD_REPORT_PATH ?? path.join(coordinationRoot, 'report.json'); + let failure; try { + await credentials.token(); let execution; try { execution = await executeConcurrentGithubReleaseAssetUploadPlan(plan, { @@ -380,6 +384,7 @@ async function publishSelectedGithubReleaseAssetSets(products, headRef, { check `(${wave.assetCount} assets, ${wave.windowMs}ms bound).`, ); return uploadFrozenReleaseAssets(uploadPlans.get(product), { + getToken: () => credentials.token(), environment: githubReleaseAssetUploadEnvironment(process.env, { abortPath, windowMs: wave.windowMs, @@ -403,10 +408,17 @@ async function publishSelectedGithubReleaseAssetSets(products, headRef, { check sourceCommit: ACTIVE_PUBLICATION_LOCK.source.commit, }); } catch (cause) { - fail(cause instanceof Error ? cause.message : String(cause)); + failure = cause; } finally { - rmSync(coordinationRoot, { force: true, recursive: true }); + try { + await credentials.close(); + } catch (cause) { + failure = new Error([failure?.message, cause.message].filter(Boolean).join('; ')); + } finally { + rmSync(coordinationRoot, { force: true, recursive: true }); + } } + if (failure) throw failure; } function releaseEnvironment(name) { @@ -944,11 +956,15 @@ if ( ) { const requested = parseProductsJson(argv.slice(1)); if (requested !== null) { - await publishSelectedGithubReleaseAssetSets( - releaseOrderedProducts(requested), - flagValue(argv.slice(1), '--head-ref') ?? 'HEAD', - { check: argv.slice(1).includes('--check') }, - ); + try { + await publishSelectedGithubReleaseAssetSets( + releaseOrderedProducts(requested), + flagValue(argv.slice(1), '--head-ref') ?? 'HEAD', + { check: argv.slice(1).includes('--check') }, + ); + } catch (cause) { + fail(cause instanceof Error ? cause.message : String(cause)); + } process.exit(0); } } diff --git a/tools/release/upload_github_release_assets.mts b/tools/release/upload_github_release_assets.mts index 0a587c201..94d09e924 100644 --- a/tools/release/upload_github_release_assets.mts +++ b/tools/release/upload_github_release_assets.mts @@ -356,6 +356,7 @@ function pendingError(message, options = {}) { function singleSnapshotReadOptions(budget, dependencies) { return remainingGitHubReadOptions(budget, { ...dependencies.singleReadOptions, + getToken: dependencies.getToken, maxAttempts: 1, }); } @@ -634,6 +635,7 @@ export async function uploadFrozenReleaseAssets(plan, dependencies = {}) { assertMutationAllowed: () => assertSharedUploadMutationAllowed(environment), deadlineMs: budget.deadlineMs, environment, + getToken: dependencies.getToken, now: budget.now, timeoutMs, },