diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index 8fffe1d17..e59c2a232 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -1,6 +1,12 @@ name: build-and-push-bskyweb-aws on: workflow_dispatch: + inputs: + sourceRef: + type: string + description: Source ref to build; use a full commit SHA for immutable builds + required: false + default: '' push: branches: - main @@ -15,6 +21,8 @@ jobs: bskyweb-container-aws: if: github.repository == 'bluesky-social/social-app' runs-on: ubuntu-latest + outputs: + source-sha: ${{ steps.source.outputs.sha }} permissions: contents: read packages: write @@ -23,6 +31,14 @@ jobs: steps: - name: ⬇️ Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.sourceRef || github.sha }} + + - name: Resolve build source + id: source + run: | + echo "Building source: $(git rev-parse HEAD)" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: 🔧 Setup Docker buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 @@ -41,7 +57,9 @@ jobs: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=sha,enable=true,priority=100,prefix=,suffix=,format=long + type=raw,value=${{ steps.source.outputs.sha }} + labels: | + org.opencontainers.image.revision=${{ steps.source.outputs.sha }} - name: 📝 Env id: env diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 88ab6dfbf..02dae7267 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -4,6 +4,11 @@ name: Build and Submit Android on: workflow_dispatch: inputs: + sourceRef: + type: string + description: Source ref to build; use a full commit SHA for immutable builds + required: false + default: '' profile: type: choice description: Build profile to use @@ -16,6 +21,11 @@ on: default: true workflow_call: inputs: + sourceRef: + type: string + description: Source ref to build; use a full commit SHA for immutable builds + required: false + default: '' profile: type: string description: Build profile to use @@ -30,6 +40,9 @@ on: required: false default: '' outputs: + source-sha: + description: Exact commit used for the build + value: ${{ jobs.build.outputs.source-sha }} package-version: description: Version from package.json value: ${{ jobs.build.outputs.package-version }} @@ -75,14 +88,22 @@ jobs: group: android-build cancel-in-progress: false outputs: + source-sha: ${{ steps.source.outputs.sha }} package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }} steps: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.sourceRef || github.sha }} fetch-depth: 5 + - name: Resolve build source + id: source + run: | + echo "Building source: $(git rev-parse HEAD)" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: 🔧 Setup Expo project uses: $/.github/actions/setup-expo-project with: @@ -153,6 +174,7 @@ jobs: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.build.outputs.source-sha }} fetch-depth: 5 - name: 🔧 Setup Expo project diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index 4c4624938..6db4bced5 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -4,6 +4,15 @@ name: Build and Submit iOS on: workflow_dispatch: inputs: + submit: + type: boolean + description: Submit the build to App Store Connect + default: true + sourceRef: + type: string + description: Source ref to build; use a full commit SHA for immutable builds + required: false + default: '' profile: type: choice description: Build profile to use @@ -25,6 +34,15 @@ on: default: '' workflow_call: inputs: + submit: + type: boolean + description: Submit the build to App Store Connect + default: true + sourceRef: + type: string + description: Source ref to build; use a full commit SHA for immutable builds + required: false + default: '' profile: type: string description: Build profile to use @@ -39,6 +57,9 @@ on: required: false default: '' outputs: + source-sha: + description: Exact commit used for the build + value: ${{ jobs.build.outputs.source-sha }} package-version: description: Version from package.json value: ${{ jobs.build.outputs.package-version }} @@ -82,14 +103,22 @@ jobs: group: ios-build cancel-in-progress: false outputs: + source-sha: ${{ steps.source.outputs.sha }} package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} build-number: ${{ steps.ipa-build-number.outputs.build-number }} steps: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.sourceRef || github.sha }} fetch-depth: 5 + - name: Resolve build source + id: source + run: | + echo "Building source: $(git rev-parse HEAD)" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: 🔧 Setup Expo project uses: $/.github/actions/setup-expo-project with: @@ -227,6 +256,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" submit: + if: ${{ inputs.submit != false }} name: Submit iOS # Submission and dSYM upload are I/O bound and don't need the xlarge builder. runs-on: macos-26 @@ -235,6 +265,7 @@ jobs: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.build.outputs.source-sha }} # eas submit reads the app config from the repo fetch-depth: 5 diff --git a/.github/workflows/prepare-cactus-release.yml b/.github/workflows/prepare-cactus-release.yml new file mode 100644 index 000000000..bbcb1d652 --- /dev/null +++ b/.github/workflows/prepare-cactus-release.yml @@ -0,0 +1,69 @@ +--- +name: Prepare Cactus Release (dry run) +run-name: Dry run release ${{ inputs.releaseVersion || '(source version)' }} from ${{ inputs.sourceRef || github.sha }} + +on: + # Temporary branch test; remove before merging. + push: + branches: + - app-2943-ota + workflow_dispatch: + inputs: + releaseVersion: + description: Release version (x.y.z; must match the source) + required: true + type: string + sourceRef: + description: Source branch, tag, or commit + required: true + default: main + type: string + +permissions: + contents: read + actions: read + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - name: Checkout release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: tooling + persist-credentials: false + - name: Checkout selected source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.sourceRef || github.sha }} + path: source + fetch-depth: 0 + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: tooling/package.json + - name: Check release and print dry run + env: + RELEASE_VERSION: ${{ inputs.releaseVersion }} + run: | + if [ "$GITHUB_EVENT_NAME" = "push" ]; then + RELEASE_VERSION=$(node -p "require('./source/package.json').version") + fi + node tooling/scripts/release/prepare.mjs "$RELEASE_VERSION" source release-preparation + - name: Plan release requests and recheck GitHub + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REPOSITORY: ${{ github.repository }} + run: node tooling/scripts/release/plan.mjs release-preparation "$RELEASE_REPOSITORY" + - name: Print report including conflicts + if: ${{ !cancelled() && hashFiles('release-preparation/README.md') != '' }} + run: cat release-preparation/README.md >> "$GITHUB_STEP_SUMMARY" + - name: Save report and release notes + if: ${{ !cancelled() && hashFiles('release-preparation/README.md') != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-preparation-${{ github.run_id }}-${{ github.run_attempt }} + path: release-preparation/ + retention-days: 14 + if-no-files-found: error diff --git a/docs/release-model.md b/docs/release-model.md new file mode 100644 index 000000000..03c72fbcc --- /dev/null +++ b/docs/release-model.md @@ -0,0 +1,71 @@ +# Release model + +`RELEASE-x.y.z.md` holds the release version, build metadata, and cumulative +public changelog. + +## Names + +All names come from one strict `x.y.z` version: + +- Branch: `release-x.y.z` +- Native tag: `x.y.z` +- Document: `RELEASE-x.y.z.md` +- GitHub Release: `Release x.y.z` +- OTA tag: `ota-x.y.z-N` + +## Prepared document + +Only `releaseVersion` is required before builds: + +```md +--- +releaseVersion: 1.131.1 +--- + +# Release 1.131.1 + + + +## Initial release + +- Added something + + +``` + +## Finalized document + +Finalization adds the source commit and build numbers read from the native +artifacts. All fields are required: + +```yaml +releaseVersion: 1.131.1 +sourceTag: 1.131.1 +sourceSha: 0123456789abcdef0123456789abcdef01234567 +iosBuildNumber: 1662 +androidVersionCode: 1110 +``` + +`sourceTag` must match `releaseVersion`. `sourceSha` must be a full Git object ID. +Build numbers must be positive integers. Collecting build results and finalizing +the document are not implemented in the preparation workflow yet. + +## Changelog + +GitHub Release notes come from between the public changelog markers. Each OTA +adds a numbered section (`OTA 1`, `OTA 2`, etc.) with no gaps in the sequence. + +## Build inputs + +The iOS, Android, and web workflows accept `sourceRef`, defaulting to the event's +SHA, and expose the checked-out commit as `source-sha`. Native submission jobs +check out that same commit. Web image tags and revision labels use it too. + +Native workflows accept `submit`, defaulting to `true`. The preparation plan sets +it to `false`. Build and submission recovery is separate work. + +## Implementation + +- [Preparation workflow and checks](release-preparation/README.md) +- `scripts/release/model.mjs`: document creation, validation, and changelog parsing. +- `scripts/release/cli.mjs`: command-line access to the document helpers. diff --git a/docs/release-preparation/README.md b/docs/release-preparation/README.md new file mode 100644 index 000000000..db2241cbd --- /dev/null +++ b/docs/release-preparation/README.md @@ -0,0 +1,55 @@ +# Release preparation + +Run **Prepare Cactus Release (dry run)** in GitHub Actions with: + +- `sourceRef`: branch, tag, or commit to release. +- `releaseVersion`: strict `x.y.z`, matching the package and Expo versions. + +The run produces an Actions summary and an artifact containing the request plan, +release file, and public notes. Reports are saved on conflicts too. + +## Plan + +1. Create or reuse the release branch. +2. Create the tree and commit for the release file, or reuse a matching commit. +3. Advance the branch and create or reuse the tag and draft release. +4. Prepare the iOS, Android, and web workflow dispatches. + +Each step rechecks GitHub against the initial state. A change or failed check +stops the run. The report includes request bodies, dependencies, and step results: +`skipped-dry-run`, `verified-reuse`, or `blocked`. Output references such as +`fromStep: prepared-commit` are resolved by the preceding request in a live run. + +## Reuse checks + +- **Branch:** points to the selected source or a single preparation commit whose + only change is the expected release file. +- **Release file:** contents match exactly. +- **Tag:** resolves to the preparation commit. +- **Draft:** tag, name, and public notes match; it isn't published or a prerelease. + +The action's read-only token may not see all drafts. That check is marked +`unverified`, and the plan finishes as `complete-with-warnings`. A draft-creation +request includes a precondition to check for existing drafts with release +permissions before execution. Conflicts and API errors still block the run. + +## Build inputs + +All three dispatches use the release tag and pass the prepared commit as +`sourceRef`. The source's workflow files must match this checkout's definitions, +and the workflows must be active in GitHub. + +- **iOS / Android:** `profile: production`, `submit: false`. +- **Web:** builds the production image and pushes it to ECR. + +## Remaining work + +- Execute writes with concurrency handling and avoid duplicate build dispatches + on retries. +- Wait for build results and record build numbers in the release file. +- Add fingerprint runtime support; validation currently requires `appVersion`. + +Release notes are commit titles since the highest reachable version tag, +excluding the requested version. Without an earlier tag, they include all +reachable history. Notes still need review, and translations aren't refreshed. +App release remains manual. diff --git a/scripts/release/check-github.mjs b/scripts/release/check-github.mjs new file mode 100644 index 000000000..aac72bead --- /dev/null +++ b/scripts/release/check-github.mjs @@ -0,0 +1,253 @@ +import {readFileSync, writeFileSync} from 'node:fs' +import {join, resolve} from 'node:path' +import {pathToFileURL} from 'node:url' + +import {renderReport} from './prepare.mjs' + +/** GET-only GitHub reader. Missing resources are established by successful lists. */ +export function githubReader(repository, token) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !token) { + throw new Error('A repository (owner/name) and GH_TOKEN are required.') + } + return async path => { + const response = await fetch( + `https://api.github.com/repos/${repository}${path ? `/${path}` : ''}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: AbortSignal.timeout(30000), + }, + ) + if (!response.ok) + throw new Error( + `GitHub GET ${path}: HTTP ${response.status}. No absence or reuse can be assumed.`, + ) + return response.json() + } +} + +/** Compare actual remote resources, accepting only an exact, single-file preparation commit. */ +export async function checkGitHub(report, read) { + const {identity, sourceSha, document, publicChangelog} = report + let observed = null + const checks = [] + const add = (resource, action, detail) => + checks.push({resource, action, detail}) + try { + const repository = await read('') + const draftVisibility = repository.permissions?.push === true + if (!draftVisibility) { + add( + 'draft visibility', + 'unverified', + 'This token cannot establish full draft visibility. Draft creation requires a check with release permissions before execution.', + ) + } + const ref = async name => { + const refs = await read(`git/matching-refs/${name}`) + if (!Array.isArray(refs)) + throw new Error('Invalid GitHub reference response.') + return refs.find(item => item.ref === `refs/${name}`) ?? null + } + const branch = await ref(`heads/${identity.branch}`) + const tag = await ref(`tags/${identity.tag}`) + const releases = [] + for (let page = 1; ; page++) { + const items = await read(`releases?per_page=100&page=${page}`) + if (!Array.isArray(items)) + throw new Error('Invalid GitHub releases response.') + releases.push(...items.filter(item => item.tag_name === identity.tag)) + if (items.length < 100) break + } + const base = await read(`git/commits/${sourceSha}`) + const before = await read(`git/trees/${base.tree.sha}?recursive=1`) + if (before.truncated || !Array.isArray(before.tree)) + throw new Error('Cannot verify the complete source tree.') + if (before.tree.some(item => item.path === identity.filename)) { + throw new Error( + 'Selected source already contains this release file. Select the original source before preparation; existing release metadata will not be overwritten.', + ) + } + let candidate = null + if (!branch) { + add('branch', 'create', `${identity.branch} does not exist.`) + add( + 'release file', + 'create', + `Would commit ${identity.filename}; no prepared commit exists yet.`, + ) + } else if (branch.object?.type !== 'commit') { + add('branch', 'blocked', 'Release branch does not point to a commit.') + } else if (branch.object.sha === sourceSha) { + add( + 'branch', + 'reuse', + `Branch still points to the selected source ${sourceSha}.`, + ) + add( + 'release file', + 'create', + 'The preparation commit has not been made yet.', + ) + } else { + const commit = await read(`git/commits/${branch.object.sha}`) + if (commit.parents?.length !== 1 || commit.parents[0].sha !== sourceSha) { + add( + 'branch', + 'blocked', + 'Branch has moved or was prepared from a different source.', + ) + } else { + const after = await read(`git/trees/${commit.tree.sha}?recursive=1`) + if ( + before.truncated || + after.truncated || + !Array.isArray(before.tree) || + !Array.isArray(after.tree) + ) + throw new Error('Cannot verify the complete Git trees.') + const entries = tree => + tree + .filter( + item => item.type !== 'tree' && item.path !== identity.filename, + ) + .map(({path, mode, type, sha}) => ({path, mode, type, sha})) + .sort((a, b) => a.path.localeCompare(b.path)) + const file = after.tree.find(item => item.path === identity.filename) + let matches = false + if ( + file?.type === 'blob' && + file.mode === '100644' && + JSON.stringify(entries(before.tree)) === + JSON.stringify(entries(after.tree)) + ) { + const blob = await read(`git/blobs/${file.sha}`) + matches = + blob.encoding === 'base64' && + Buffer.from(blob.content, 'base64').toString('utf8') === document + } + if (matches) { + candidate = branch.object.sha + add( + 'branch', + 'reuse', + `Verified preparation commit ${candidate}: same source, same release file, no other file changes.`, + ) + add( + 'release file', + 'reuse', + 'Contents already match; no new commit is needed.', + ) + } else + add( + 'branch', + 'blocked', + 'Release file or other files differ from the expected preparation.', + ) + } + } + observed = { + branch, + tag, + releases, + sourceTreeSha: base.tree.sha, + sourceTree: before.tree, + candidateSha: candidate, + draftVisibility, + } + if (!tag) + add( + 'tag', + 'create', + `${identity.tag} does not exist. It would point to the prepared commit.`, + ) + else { + let object = tag.object + for (let depth = 0; object?.type === 'tag' && depth < 10; depth++) + object = (await read(`git/tags/${object.sha}`)).object + add( + 'tag', + candidate && object?.type === 'commit' && object.sha === candidate + ? 'reuse' + : 'blocked', + `Existing tag resolves to ${object?.sha ?? 'an unknown object'}. It must match a verified preparation commit.`, + ) + } + if (releases.length === 0) + add( + 'GitHub Release', + draftVisibility ? 'create' : 'unverified', + draftVisibility + ? `No release uses tag ${identity.tag}.` + : `No visible release uses tag ${identity.tag}; a hidden draft may exist.`, + ) + else if (releases.length !== 1) + add('GitHub Release', 'blocked', 'Multiple releases use this tag.') + else { + const release = releases[0] + const matches = + candidate && + tag && + checks.find(item => item.resource === 'tag')?.action === 'reuse' && + release.draft === true && + release.prerelease === false && + release.name === identity.githubReleaseName && + release.body === publicChangelog + add( + 'GitHub Release', + matches ? 'reuse' : 'blocked', + matches + ? `Draft #${release.id} matches the verified tag and public notes.` + : 'Existing release is published, differs from the expected draft, or has no verified tag and preparation commit.', + ) + } + } catch (error) { + add('GitHub checks', 'blocked', error.message) + } + return { + ...report, + github: { + status: checks.some(item => item.action === 'blocked') + ? 'blocked' + : checks.some(item => item.action === 'unverified') + ? 'incomplete' + : 'ready', + checkedAt: new Date().toISOString(), + checks, + observed, + }, + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + const [directory, repository, ...extra] = process.argv.slice(2) + if (!directory || !repository || extra.length) + throw new Error( + 'Usage: node scripts/release/check-github.mjs REPORT_DIRECTORY OWNER/REPO', + ) + const report = await checkGitHub( + JSON.parse(readFileSync(join(directory, 'report.json'), 'utf8')), + githubReader(repository, process.env.GH_TOKEN), + ) + report.github.repository = repository + writeFileSync( + join(directory, 'report.json'), + JSON.stringify(report, null, 2) + '\n', + ) + const markdown = renderReport(report) + writeFileSync(join(directory, 'README.md'), markdown) + process.stdout.write(markdown) + if (report.github.status === 'blocked') process.exitCode = 1 + } catch (error) { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 + } +} diff --git a/scripts/release/check-github.test.js b/scripts/release/check-github.test.js new file mode 100644 index 000000000..81edcbdeb --- /dev/null +++ b/scripts/release/check-github.test.js @@ -0,0 +1,326 @@ +/* oxlint-disable import/no-nodejs-modules -- Tests execute the Node release checker with API fixtures. */ +import {spawnSync} from 'node:child_process' +import {resolve} from 'node:path' +import {pathToFileURL} from 'node:url' + +const plannerUrl = pathToFileURL(resolve('scripts/release/plan.mjs')).href + +const moduleUrl = pathToFileURL( + resolve('scripts/release/check-github.mjs'), +).href + +/** Run the real checker with a strict fake reader; no network calls. */ +function check(scenario) { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import {checkGitHub, githubReader} from ${JSON.stringify(moduleUrl)} + import {dryRunRelease, buildWorkflows} from ${JSON.stringify(plannerUrl)} + const scenario = process.argv[1] + const hashes = Object.fromEntries(buildWorkflows.map(build => [build.file, build.file])) + const report = {identity: {branch:'release-1.2.3',tag:'1.2.3',filename:'RELEASE-1.2.3.md',githubReleaseName:'Release 1.2.3'},sourceSha:'source',document:'exact document',publicChangelog:'notes'} + const file = {path:'app.js',mode:'100644',type:'blob',sha:'app'} + const data = { + '': {permissions:{push:!scenario.includes('hidden')}}, + 'git/matching-refs/heads/release-1.2.3': [{ref:'refs/heads/release-1.2.3',object:{type:'commit',sha:'candidate'}}], + 'git/matching-refs/tags/1.2.3': [{ref:'refs/tags/1.2.3',object:{type:'tag',sha:'annotated'}}], + 'git/tags/annotated': {object:{type:'commit',sha:'candidate'}}, + 'git/commits/candidate': {parents:[{sha:'source'}],tree:{sha:'after'}}, + 'git/commits/source': {tree:{sha:'before'}}, + 'git/trees/before?recursive=1': {tree:[file]}, + 'git/trees/after?recursive=1': {tree:[file,{path:report.identity.filename,type:'blob',mode:'100644',sha:'document'}]}, + 'git/blobs/document': {encoding:'base64',content:Buffer.from('exact document').toString('base64')}, + 'releases?per_page=100&page=1': [{id:1,tag_name:'1.2.3',draft:true,prerelease:false,name:'Release 1.2.3',body:'notes'}], + } + if (scenario === 'new' || ['plan-new','plan-drift','plan-workflow','plan-hidden'].includes(scenario)) for (const key of Object.keys(data)) if (key.includes('matching-refs') || key.startsWith('releases')) data[key] = [] + if (scenario === 'moved') data['git/commits/candidate'].parents = [{sha:'other'}] + if (scenario === 'changed') data['git/trees/after?recursive=1'].tree[0] = {...file,sha:'changed'} + if (scenario === 'truncated') data['git/trees/after?recursive=1'].truncated = true + if (scenario === 'published' || scenario === 'plan-hidden-published') data['releases?per_page=100&page=1'][0].draft = false + if (scenario === 'pagination') { + data['releases?per_page=100&page=2'] = data['releases?per_page=100&page=1'] + data['releases?per_page=100&page=1'] = Array.from({length:100},()=>({tag_name:'other'})) + } + if (scenario.startsWith('plan-')) { + const files = buildWorkflows.map(build => ({path:'.github/workflows/' + build.file,mode:'100644',type:'blob',sha:build.file})) + data['git/trees/before?recursive=1'].tree.push(...files) + data['git/trees/after?recursive=1'].tree.push(...files) + } + if (scenario === 'plan-workflow') data['git/trees/before?recursive=1'].tree.pop() + let passes = 0 + for (const build of buildWorkflows) data['actions/workflows/' + build.file] = {state:scenario === 'plan-disabled' ? 'disabled_manually' : 'active',path:'.github/workflows/' + build.file} + const read = async path => { + if (path === '') passes++ + if (scenario === 'plan-drift' && passes === 3 && path === 'git/matching-refs/heads/release-1.2.3') return [{ref:'refs/heads/release-1.2.3',object:{type:'commit',sha:'source'}}] + + if (scenario === 'api-error') throw new Error('HTTP 403') + if (!(path in data)) throw new Error('Unexpected request ' + path) + return data[path] + } + const checked = scenario.startsWith('plan-') ? await dryRunRelease(report, 'owner/repo', read, hashes) : await checkGitHub(report, read) + if (scenario === 'get-only') { + globalThis.fetch = async (url, options) => { + if (url !== 'https://api.github.com/repos/owner/repo') throw new Error('Wrong repository endpoint') + if (options.method !== 'GET' || options.body) throw new Error('Mutation attempted') + return {ok:false,status:404} + } + try { await githubReader('owner/repo','dummy')('') } catch(error) { checked.transportError = error.message } + } + process.stdout.write(JSON.stringify(checked)) + `, + scenario, + ], + {encoding: 'utf8'}, + ) + if (result.status !== 0) throw new Error(result.stderr) + return JSON.parse(result.stdout) +} + +test('missing resources propose creation; matching resources including annotated tags are reused', () => { + expect(check('new')).toMatchObject({ + github: { + status: 'ready', + checks: expect.arrayContaining([ + expect.objectContaining({resource: 'branch', action: 'create'}), + ]), + }, + }) + expect(check('matching')).toMatchObject({ + github: { + status: 'ready', + checks: expect.arrayContaining([ + expect.objectContaining({resource: 'release file', action: 'reuse'}), + expect.objectContaining({resource: 'GitHub Release', action: 'reuse'}), + ]), + }, + }) +}) + +test('moved branches, unrelated file changes, published releases, and incomplete evidence block preparation', () => { + for (const scenario of [ + 'moved', + 'changed', + 'published', + 'truncated', + 'api-error', + ]) { + expect(check(scenario)).toMatchObject({github: {status: 'blocked'}}) + } +}) + +test('checks later pages of releases', () => { + expect(check('pagination')).toMatchObject({ + github: { + status: 'ready', + checks: expect.arrayContaining([ + expect.objectContaining({resource: 'GitHub Release', action: 'reuse'}), + ]), + }, + }) +}) + +test('reader only sends GET and treats HTTP errors as failures, not missing resources', () => { + expect(check('get-only')).toMatchObject({ + transportError: expect.stringContaining('HTTP 404'), + }) +}) + +test('new preparation prints dependent requests and skips every write and build dispatch', () => { + expect(check('plan-new')).toMatchObject({ + execution: { + status: 'complete', + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'prepared-commit', + result: 'skipped-dry-run', + request: expect.objectContaining({ + body: expect.objectContaining({ + tree: {fromStep: 'release-tree', field: 'sha'}, + parents: ['source'], + }), + }), + }), + expect.objectContaining({ + id: 'branch-tip', + request: expect.objectContaining({ + body: { + sha: {fromStep: 'prepared-commit', field: 'sha'}, + force: false, + }, + }), + }), + expect.objectContaining({ + id: 'build-ios', + result: 'skipped-dry-run', + request: expect.objectContaining({ + body: { + ref: '1.2.3', + inputs: { + profile: 'production', + submit: false, + testFlightGroup: 'none', + sourceRef: {fromStep: 'prepared-commit', field: 'sha'}, + }, + }, + }), + }), + expect.objectContaining({ + id: 'build-web', + result: 'skipped-dry-run', + request: expect.objectContaining({ + body: { + ref: '1.2.3', + inputs: {sourceRef: {fromStep: 'prepared-commit', field: 'sha'}}, + }, + }), + }), + ]), + }, + }) +}) + +test('retry reuses the verified commit and puts its real SHA into native build inputs', () => { + expect(check('plan-matching')).toMatchObject({ + execution: { + status: 'complete', + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'prepared-commit', + action: 'reuse', + value: 'candidate', + }), + expect.objectContaining({ + id: 'build-android', + request: expect.objectContaining({ + body: expect.objectContaining({ + inputs: { + profile: 'production', + submit: false, + sourceRef: 'candidate', + }, + }), + }), + }), + ]), + }, + }) +}) + +test('a change between proposed writes stops the remaining plan', () => { + expect(check('plan-drift')).toMatchObject({ + execution: { + status: 'blocked', + steps: expect.arrayContaining([ + expect.objectContaining({id: 'branch', result: 'skipped-dry-run'}), + expect.objectContaining({id: 'release-tree', result: 'blocked'}), + ]), + }, + }) +}) + +test('a missing or changed build workflow blocks all proposed operations', () => { + expect(check('plan-drift')).not.toMatchObject({ + execution: { + steps: expect.arrayContaining([ + expect.objectContaining({id: 'build-web', result: 'skipped-dry-run'}), + ]), + }, + }) + expect(check('plan-disabled')).toMatchObject({ + execution: {status: 'blocked', reason: expect.stringContaining('disabled')}, + }) + + expect(check('plan-workflow')).toMatchObject({ + execution: { + status: 'blocked', + reason: expect.stringContaining('build-and-push-bskyweb-aws.yaml'), + }, + }) +}) + +test('planned build inputs match the actual dispatch definitions and no live option exists', () => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import {readFileSync} from 'node:fs' + import {createRequire} from 'node:module' + import assert from 'node:assert/strict' + import {buildWorkflows,workflowHashes} from ${JSON.stringify(plannerUrl)} + const yaml = createRequire(import.meta.url)('js-yaml') + for (const build of buildWorkflows) { + const workflow = yaml.load(readFileSync('.github/workflows/' + build.file,'utf8')) + assert('workflow_dispatch' in workflow.on) + const definitions = workflow.on.workflow_dispatch?.inputs ?? {} + const inputs = {...build.inputs,sourceRef:'candidate'} + for (const [key,value] of Object.entries(inputs)) { + assert(definitions[key], key) + if (definitions[key].type === 'boolean') assert.equal(typeof value,'boolean') + if (definitions[key].options) assert(definitions[key].options.includes(value)) + } + for (const [key,definition] of Object.entries(definitions)) if (definition.required && definition.default === undefined) assert(key in inputs) + assert.match(workflowHashes()[build.file], /^[a-f0-9]{40}$/) + const job = workflow.jobs.build ?? workflow.jobs['bskyweb-container-aws'] + const checkout = job.steps.find(step => step.uses?.startsWith('actions/checkout@')) + assert.equal(checkout.with.ref, '${'$'}{{ inputs.sourceRef || github.sha }}') + assert.equal(job.outputs['source-sha'], '${'$'}{{ steps.source.outputs.sha }}') + if (build.platform !== 'web') { + assert.equal(workflow.on.workflow_call.inputs.sourceRef.default, '') + assert.equal(workflow.on.workflow_call.inputs.submit.default, true) + assert.equal(workflow.jobs.submit.if, '${'$'}{{ inputs.submit != false }}') + assert.equal(workflow.jobs.submit.steps.find(step => step.uses?.startsWith('actions/checkout@')).with.ref, '${'$'}{{ needs.build.outputs.source-sha }}') + } else { + const metadata = job.steps.find(step => step.uses?.startsWith('docker/metadata-action@')) + assert(metadata.with.tags.includes('${'$'}{{ steps.source.outputs.sha }}')) + assert(metadata.with.labels.includes('${'$'}{{ steps.source.outputs.sha }}')) + } + + } + `, + ], + {encoding: 'utf8'}, + ) + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + const live = spawnSync( + process.execPath, + [resolve('scripts/release/plan.mjs'), 'report', 'owner/repo', '--apply'], + {encoding: 'utf8'}, + ) + expect(live.status).toBe(1) + expect(live.stderr).toContain('Live execution is not available') +}) + +test('read-only draft visibility allows a conditional dry-run plan but still catches conflicts', () => { + expect(check('plan-hidden')).toMatchObject({ + github: { + status: 'incomplete', + checks: expect.arrayContaining([ + expect.objectContaining({ + resource: 'GitHub Release', + action: 'unverified', + }), + ]), + }, + execution: { + status: 'complete-with-warnings', + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'draft', + precondition: expect.stringContaining('Confirm that no draft exists'), + result: 'skipped-dry-run', + }), + expect.objectContaining({id: 'build-web', result: 'skipped-dry-run'}), + ]), + }, + }) + expect(check('plan-hidden-published')).toMatchObject({ + github: {status: 'blocked'}, + execution: {status: 'blocked'}, + }) +}) diff --git a/scripts/release/cli.mjs b/scripts/release/cli.mjs new file mode 100644 index 000000000..46cdbae8a --- /dev/null +++ b/scripts/release/cli.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import {readFileSync, writeFileSync} from 'node:fs' + +import { + ReleaseError, + appendOtaChangelog, + createReleaseDocument, + deriveReleaseIdentity, + extractPublicChangelog, + finalizeReleaseDocument, + parseReleaseDocument, +} from './model.mjs' + +function usage() { + return `Usage: + node scripts/release/cli.mjs identity + node scripts/release/cli.mjs create + node scripts/release/cli.mjs validate [prepared|final] + node scripts/release/cli.mjs extract-public + node scripts/release/cli.mjs append-ota + node scripts/release/cli.mjs finalize ` +} + +function requireArgs(args, count) { + if (args.length !== count) throw new ReleaseError(usage()) +} + +function read(path) { + return readFileSync(path, 'utf8') +} + +function write(path, content) { + writeFileSync(path, content, 'utf8') +} + +function run([command, ...args]) { + switch (command) { + case 'identity': { + requireArgs(args, 1) + console.log(JSON.stringify(deriveReleaseIdentity(args[0]), null, 2)) + return + } + case 'create': { + requireArgs(args, 3) + const [version, changelogFile, outputFile] = args + write(outputFile, createReleaseDocument(version, read(changelogFile))) + console.log(`Created ${outputFile}`) + return + } + case 'validate': { + if (args.length < 1 || args.length > 2) throw new ReleaseError(usage()) + const [releaseFile, stage = 'prepared'] = args + const parsed = parseReleaseDocument(read(releaseFile), { + filename: releaseFile.split('/').at(-1), + stage, + }) + console.log( + `Validated ${releaseFile} as a ${stage} release for ${parsed.metadata.releaseVersion}`, + ) + return + } + case 'extract-public': { + requireArgs(args, 1) + process.stdout.write(`${extractPublicChangelog(read(args[0]))}\n`) + return + } + case 'append-ota': { + requireArgs(args, 3) + const [releaseFile, rawSequence, changelogFile] = args + const sequence = Number(rawSequence) + write( + releaseFile, + appendOtaChangelog(read(releaseFile), sequence, read(changelogFile)), + ) + console.log(`Added OTA ${rawSequence} to ${releaseFile}`) + return + } + case 'finalize': { + requireArgs(args, 5) + const [ + releaseFile, + sourceTag, + sourceSha, + iosBuildNumber, + androidVersionCode, + ] = args + write( + releaseFile, + finalizeReleaseDocument(read(releaseFile), { + sourceTag, + sourceSha, + iosBuildNumber, + androidVersionCode, + }), + ) + console.log(`Finalized ${releaseFile}`) + return + } + default: + throw new ReleaseError(usage()) + } +} + +try { + run(process.argv.slice(2)) +} catch (error) { + if (error instanceof ReleaseError) { + console.error(`Error: ${error.message}`) + process.exitCode = 1 + } else { + throw error + } +} diff --git a/scripts/release/model.mjs b/scripts/release/model.mjs new file mode 100644 index 000000000..b7d2c931e --- /dev/null +++ b/scripts/release/model.mjs @@ -0,0 +1,293 @@ +const VERSION_COMPONENT = '(?:0|[1-9][0-9]*)' +const VERSION_PATTERN = new RegExp( + `^${VERSION_COMPONENT}\\.${VERSION_COMPONENT}\\.${VERSION_COMPONENT}$`, +) +const RELEASE_FILENAME_PATTERN = new RegExp( + `^RELEASE-(${VERSION_COMPONENT}\\.${VERSION_COMPONENT}\\.${VERSION_COMPONENT})\\.md$`, +) +const SOURCE_SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ +const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/ + +const FRONTMATTER_KEYS = [ + 'releaseVersion', + 'sourceTag', + 'sourceSha', + 'iosBuildNumber', + 'androidVersionCode', +] + +const PUBLIC_CHANGELOG_START = '' +const PUBLIC_CHANGELOG_END = '' + +export class ReleaseError extends Error { + constructor(message) { + super(message) + this.name = 'ReleaseError' + } +} + +function fail(message) { + throw new ReleaseError(message) +} + +function normalizeLineEndings(markdown) { + return markdown.replace(/\r\n?/g, '\n') +} + +export function assertReleaseVersion(version) { + if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) { + fail(`Release version must use strict x.y.z format; found '${version}'.`) + } + return version +} + +export function deriveReleaseIdentity(version) { + assertReleaseVersion(version) + return Object.freeze({ + version, + branch: `release-${version}`, + tag: version, + filename: `RELEASE-${version}.md`, + githubReleaseName: `Release ${version}`, + }) +} + +function parseFrontmatter(markdown) { + const normalized = normalizeLineEndings(markdown) + if (!normalized.startsWith('---\n')) { + fail('Release file must start with YAML frontmatter.') + } + + const end = normalized.indexOf('\n---\n', 4) + if (end === -1) fail('Release file frontmatter is not closed.') + + const metadata = {} + const lines = normalized.slice(4, end).split('\n') + for (const line of lines) { + if (!line.trim()) continue + const match = /^([A-Za-z][A-Za-z0-9]*):[ \t]*(.*)$/.exec(line) + if (!match) fail(`Unsupported frontmatter line: '${line}'.`) + + const [, key, rawValue] = match + if (!FRONTMATTER_KEYS.includes(key)) { + fail(`Unknown release frontmatter field '${key}'.`) + } + if (Object.hasOwn(metadata, key)) { + fail(`Duplicate release frontmatter field '${key}'.`) + } + if (!rawValue) fail(`Release frontmatter field '${key}' cannot be empty.`) + metadata[key] = rawValue + } + + const body = normalized.slice(end + 5) + if (!body.startsWith('\n')) { + fail('Release file must contain a blank line after frontmatter.') + } + + return {metadata, body: body.slice(1)} +} + +function findDelimitedSection(body, startMarker, endMarker) { + const start = body.indexOf(startMarker) + const end = body.indexOf(endMarker) + if (start === -1 || end === -1) { + fail('Release file must contain the public changelog delimiters.') + } + if ( + start !== body.lastIndexOf(startMarker) || + end !== body.lastIndexOf(endMarker) + ) { + fail('Public changelog delimiters must occur exactly once.') + } + if (end < start) + fail('Public changelog end delimiter precedes its start delimiter.') + + return { + before: body.slice(0, start + startMarker.length), + content: body.slice(start + startMarker.length, end).trim(), + after: body.slice(end), + } +} + +function parseChangelogSections(publicChangelog) { + const unsupportedHeading = publicChangelog.match( + /^## (?!Initial release\s*$|OTA [1-9][0-9]*\s*$).+$/m, + ) + if (unsupportedHeading) { + fail(`Unsupported public changelog section '${unsupportedHeading[0]}'.`) + } + + const headings = [ + ...publicChangelog.matchAll( + /^## (Initial release|OTA ([1-9][0-9]*))\s*$/gm, + ), + ] + if (!headings.length || headings[0][1] !== 'Initial release') { + fail("Public changelog must begin with an '## Initial release' section.") + } + + const prefix = publicChangelog.slice(0, headings[0].index).trim() + if (prefix) + fail( + 'Public changelog cannot contain content before its initial release section.', + ) + + const sections = headings.map((heading, index) => { + const start = heading.index + heading[0].length + const end = headings[index + 1]?.index ?? publicChangelog.length + const content = publicChangelog.slice(start, end).trim() + if (!content) fail(`Changelog section '${heading[1]}' cannot be empty.`) + return { + type: heading[1] === 'Initial release' ? 'initial' : 'ota', + sequence: heading[2] ? Number(heading[2]) : null, + content, + } + }) + + sections.slice(1).forEach((section, index) => { + const expected = index + 1 + if (section.type !== 'ota' || section.sequence !== expected) { + fail( + `OTA changelog sections must be contiguous; expected OTA ${expected}.`, + ) + } + }) + + return sections +} + +function validateMetadata(metadata, stage) { + assertReleaseVersion(metadata.releaseVersion) + + if (metadata.sourceTag !== undefined) { + assertReleaseVersion(metadata.sourceTag) + if (metadata.sourceTag !== metadata.releaseVersion) { + fail('sourceTag must match releaseVersion.') + } + } + if ( + metadata.sourceSha !== undefined && + !SOURCE_SHA_PATTERN.test(metadata.sourceSha) + ) { + fail('sourceSha must be a full lowercase hexadecimal Git object ID.') + } + for (const key of ['iosBuildNumber', 'androidVersionCode']) { + if ( + metadata[key] !== undefined && + !POSITIVE_INTEGER_PATTERN.test(metadata[key]) + ) { + fail(`${key} must be a positive integer.`) + } + } + + if (stage === 'final') { + for (const key of FRONTMATTER_KEYS) { + if (metadata[key] === undefined) { + fail(`Finalized release file is missing '${key}'.`) + } + } + } +} + +export function parseReleaseDocument(markdown, options = {}) { + if (typeof markdown !== 'string') fail('Release document must be a string.') + const stage = options.stage ?? 'prepared' + if (!['prepared', 'final'].includes(stage)) { + fail(`Unknown release validation stage '${stage}'.`) + } + + const {metadata, body} = parseFrontmatter(markdown) + validateMetadata(metadata, stage) + + const expectedTitle = `# Release ${metadata.releaseVersion}` + if (!body.startsWith(`${expectedTitle}\n`)) { + fail( + `Release document must begin with '${expectedTitle}' after frontmatter.`, + ) + } + + if (options.filename !== undefined) { + const filenameMatch = RELEASE_FILENAME_PATTERN.exec(options.filename) + if (!filenameMatch || filenameMatch[1] !== metadata.releaseVersion) { + fail(`Filename must be 'RELEASE-${metadata.releaseVersion}.md'.`) + } + } + + const delimited = findDelimitedSection( + body, + PUBLIC_CHANGELOG_START, + PUBLIC_CHANGELOG_END, + ) + const sections = parseChangelogSections(delimited.content) + + return Object.freeze({ + metadata: Object.freeze({...metadata}), + publicChangelog: delimited.content, + sections: Object.freeze(sections.map(section => Object.freeze(section))), + }) +} + +export function createReleaseDocument(version, initialChangelog) { + assertReleaseVersion(version) + const content = initialChangelog?.trim() + if (!content) fail('Initial release changelog cannot be empty.') + + return `--- +releaseVersion: ${version} +--- + +# Release ${version} + +${PUBLIC_CHANGELOG_START} + +## Initial release + +${content} + +${PUBLIC_CHANGELOG_END} +` +} + +export function extractPublicChangelog(markdown) { + return parseReleaseDocument(markdown).publicChangelog +} + +export function appendOtaChangelog(markdown, sequence, changelog) { + const normalized = normalizeLineEndings(markdown) + const parsed = parseReleaseDocument(normalized) + const expectedSequence = parsed.sections.length + if (!Number.isSafeInteger(sequence) || sequence !== expectedSequence) { + fail(`Next OTA sequence must be ${expectedSequence}; found '${sequence}'.`) + } + const content = changelog?.trim() + if (!content) fail('OTA changelog cannot be empty.') + + const delimiter = `\n\n${PUBLIC_CHANGELOG_END}` + if (!normalized.includes(delimiter)) { + fail('Could not find the public changelog end delimiter to append the OTA.') + } + const replacement = `\n\n## OTA ${sequence}\n\n${content}${delimiter}` + const updated = normalized.replace(delimiter, replacement) + parseReleaseDocument(updated) + return updated +} + +export function finalizeReleaseDocument(markdown, finalMetadata) { + const normalized = normalizeLineEndings(markdown) + const parsed = parseReleaseDocument(normalized) + const metadata = {...parsed.metadata, ...finalMetadata} + validateMetadata(metadata, 'final') + + const frontmatter = FRONTMATTER_KEYS.map( + key => `${key}: ${metadata[key]}`, + ).join('\n') + const frontmatterPattern = /^---\n[\s\S]*?\n---\n/ + if (!frontmatterPattern.test(normalized)) + fail('Could not replace the release frontmatter.') + const updated = normalized.replace( + frontmatterPattern, + `---\n${frontmatter}\n---\n`, + ) + parseReleaseDocument(updated, {stage: 'final'}) + return updated +} diff --git a/scripts/release/plan.mjs b/scripts/release/plan.mjs new file mode 100644 index 000000000..51a0558e1 --- /dev/null +++ b/scripts/release/plan.mjs @@ -0,0 +1,287 @@ +import {createHash} from 'node:crypto' +import {readFileSync, writeFileSync} from 'node:fs' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath, pathToFileURL} from 'node:url' + +import {checkGitHub, githubReader} from './check-github.mjs' +import {renderReport} from './prepare.mjs' + +export const buildWorkflows = [ + { + platform: 'iOS', + file: 'build-submit-ios.yml', + inputs: {profile: 'production', submit: false, testFlightGroup: 'none'}, + }, + { + platform: 'Android', + file: 'build-submit-android.yml', + inputs: {profile: 'production', submit: false}, + }, + { + platform: 'web', + file: 'build-and-push-bskyweb-aws.yaml', + inputs: {}, + }, +] + +/** Keep dispatch plans tied to the workflow definitions reviewed with this tool. */ +export function workflowHashes() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + return Object.fromEntries( + buildWorkflows.map(({file}) => { + const content = readFileSync(join(root, '.github/workflows', file)) + const sha = createHash('sha1') + .update(`blob ${content.length}\0`) + .update(content) + .digest('hex') + return [file, sha] + }), + ) +} + +/** An output reference is deliberately not a fake Git SHA or an executable API value. */ +function output(step, field) { + return {fromStep: step, field} +} + +/** Construct ordered request templates. This function cannot send them. */ +export function planRelease(report, repository, hashes) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) + throw new Error('Expected repository owner/name.') + const plan = { + mode: 'dry-run', + status: 'planned', + steps: [], + pending: [ + 'Build completion and artifact-derived build numbers.', + 'Final release file and manual app release.', + ], + } + if ( + !['ready', 'incomplete'].includes(report.github?.status) || + !report.github.observed + ) + return { + ...plan, + status: 'blocked', + reason: 'GitHub checks must pass before requests can be planned.', + } + plan.warnings = report.github.checks + .filter(check => check.action === 'unverified') + .map(check => check.detail) + const {identity, sourceSha, document, publicChangelog} = report + const {sourceTreeSha, sourceTree, candidateSha} = report.github.observed + const action = resource => + report.github.checks.find(check => check.resource === resource)?.action + const add = (id, label, method, path, body, dependsOn = []) => + plan.steps.push({ + id, + label, + dependsOn, + request: {method, path: `repos/${repository}/${path}`, body}, + }) + const reuse = (id, label, value) => + plan.steps.push({id, label, action: 'reuse', value}) + if (action('branch') === 'reuse') + reuse( + 'branch', + 'Reuse release branch', + report.github.observed.branch.object.sha, + ) + else + add('branch', 'Create release branch', 'POST', 'git/refs', { + ref: `refs/heads/${identity.branch}`, + sha: sourceSha, + }) + let candidate = candidateSha + if (candidate) + reuse('prepared-commit', 'Reuse verified release file commit', candidate) + else { + add( + 'release-tree', + 'Write release file into a new Git tree', + 'POST', + 'git/trees', + { + base_tree: sourceTreeSha, + tree: [ + { + path: identity.filename, + mode: '100644', + type: 'blob', + content: document, + }, + ], + }, + ['branch'], + ) + add( + 'prepared-commit', + 'Commit release file', + 'POST', + 'git/commits', + { + message: `Prepare release ${identity.version}`, + tree: output('release-tree', 'sha'), + parents: [sourceSha], + }, + ['release-tree'], + ) + candidate = output('prepared-commit', 'sha') + add( + 'branch-tip', + 'Advance release branch to prepared commit', + 'PATCH', + `git/refs/heads/${identity.branch}`, + {sha: candidate, force: false}, + ['prepared-commit'], + ) + } + if (action('tag') === 'reuse') + reuse('tag', 'Reuse verified release tag', identity.tag) + else + add( + 'tag', + 'Create release tag', + 'POST', + 'git/refs', + {ref: `refs/tags/${identity.tag}`, sha: candidate}, + [candidateSha ? 'prepared-commit' : 'branch-tip'], + ) + if (action('GitHub Release') === 'reuse') + reuse( + 'draft', + 'Reuse matching draft release', + report.github.observed.releases[0].id, + ) + else + add( + 'draft', + 'Create draft GitHub Release', + 'POST', + 'releases', + { + tag_name: identity.tag, + target_commitish: candidate, + name: identity.githubReleaseName, + body: publicChangelog, + draft: true, + prerelease: false, + }, + ['tag'], + ) + if (action('GitHub Release') === 'unverified') { + plan.steps.find(step => step.id === 'draft').precondition = + `Confirm that no draft exists for tag ${identity.tag} with a token that has release permissions.` + } + for (const build of buildWorkflows) { + const path = `.github/workflows/${build.file}` + const file = sourceTree.find(entry => entry.path === path) + if (!hashes[build.file] || file?.sha !== hashes[build.file]) { + plan.status = 'blocked' + plan.reason = `Selected source has a missing or different ${path}; its build inputs have not been verified by this tool.` + } + add( + `build-${build.platform.toLowerCase()}`, + `Request ${build.platform} build`, + 'POST', + `actions/workflows/${build.file}/dispatches`, + { + ref: identity.tag, + inputs: { + ...build.inputs, + sourceRef: candidate, + }, + }, + ['draft'], + ) + } + plan.notes = [ + 'Requests are templates only. Objects with fromStep/field refer to outputs that do not exist until a live request succeeds.', + 'All workflow dispatches use the release tag and receive the exact prepared commit. Native builds have store submission disabled.', + 'The web workflow would build and push a production image to ECR. No workflow is dispatched in this dry run.', + 'Existing build runs and receipts are not checked here. A future live runner must prevent duplicate dispatches when retrying.', + ] + return plan +} + +/** Re-read GitHub before each proposed operation, but never execute a write or dispatch. */ +export async function dryRunRelease( + report, + repository, + read, + hashes = workflowHashes(), +) { + const checked = await checkGitHub(report, read) + const plan = planRelease(checked, repository, hashes) + const baseline = JSON.stringify(checked.github.observed) + if (plan.status !== 'blocked') { + for (const step of plan.steps) { + const fresh = await checkGitHub(report, read) + step.checkedAt = fresh.github.checkedAt + if ( + !['ready', 'incomplete'].includes(fresh.github.status) || + JSON.stringify(fresh.github.observed) !== baseline + ) { + plan.status = 'blocked' + plan.reason = `GitHub changed or could not be verified before ${step.id}. No requests were sent.` + step.result = 'blocked' + plan.latestChecks = fresh.github.checks + break + } + const build = buildWorkflows.find( + item => step.id === `build-${item.platform.toLowerCase()}`, + ) + if (build) { + try { + const workflow = await read(`actions/workflows/${build.file}`) + if ( + workflow.state !== 'active' || + workflow.path !== `.github/workflows/${build.file}` + ) + throw new Error('Workflow is disabled or its path differs.') + } catch (error) { + plan.status = 'blocked' + plan.reason = `Cannot request ${build.platform} build: ${error.message}` + step.result = 'blocked' + break + } + } + step.result = + step.action === 'reuse' ? 'verified-reuse' : 'skipped-dry-run' + } + if (plan.status !== 'blocked') + plan.status = plan.warnings.length ? 'complete-with-warnings' : 'complete' + } + return {...checked, execution: plan} +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + const [directory, repository, ...extra] = process.argv.slice(2) + if (!directory || !repository || extra.length) + throw new Error( + 'Usage: node scripts/release/plan.mjs REPORT_DIRECTORY OWNER/REPO. Live execution is not available.', + ) + const report = await dryRunRelease( + JSON.parse(readFileSync(join(directory, 'report.json'), 'utf8')), + repository, + githubReader(repository, process.env.GH_TOKEN), + ) + report.github.repository = repository + writeFileSync( + join(directory, 'report.json'), + JSON.stringify(report, null, 2) + '\n', + ) + const markdown = renderReport(report) + writeFileSync(join(directory, 'README.md'), markdown) + process.stdout.write(markdown) + if (report.execution.status === 'blocked') process.exitCode = 1 + } catch (error) { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 + } +} diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs new file mode 100644 index 000000000..20df814b3 --- /dev/null +++ b/scripts/release/prepare.mjs @@ -0,0 +1,214 @@ +import {execFileSync} from 'node:child_process' +import {mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {createRequire} from 'node:module' +import {join, resolve} from 'node:path' +import {pathToFileURL} from 'node:url' + +import { + createReleaseDocument, + deriveReleaseIdentity, + parseReleaseDocument, +} from './model.mjs' + +/** Read a selected checkout and describe release preparation without changing it. */ +export function prepareRelease(sourceDirectory, releaseVersion) { + const identity = deriveReleaseIdentity(releaseVersion) + const source = resolve(sourceDirectory) + const git = (...args) => + execFileSync('git', ['-C', source, ...args], {encoding: 'utf8'}).trim() + if (git('status', '--porcelain', '--untracked-files=no')) { + throw new Error( + 'The source checkout has tracked changes. Commit or discard them first.', + ) + } + if (git('rev-parse', '--is-shallow-repository') !== 'false') { + throw new Error('Full Git history is required to generate the changelog.') + } + const sourceSha = git('rev-parse', 'HEAD') + const packageVersion = JSON.parse( + readFileSync(join(source, 'package.json'), 'utf8'), + ).version + const require = createRequire(join(source, 'package.json')) + delete require.cache[require.resolve('./app.config.js')] + const expo = require('./app.config.js')({}).expo + if (packageVersion !== releaseVersion || expo.version !== releaseVersion) { + throw new Error( + `Requested ${releaseVersion}, but package version is ${packageVersion} and Expo version is ${expo.version}.`, + ) + } + if (expo.runtimeVersion?.policy !== 'appVersion') { + throw new Error( + 'Expected Expo runtimeVersion.policy to be appVersion. Review the runtime policy before preparing this release.', + ) + } + const previousTag = + git('tag', '--merged', sourceSha, '--sort=-version:refname') + .split('\n') + .find( + tag => + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(tag) && + tag !== releaseVersion, + ) || null + const changelog = + git( + 'log', + '--no-merges', + '--pretty=format:- %s', + previousTag ? `${previousTag}..${sourceSha}` : sourceSha, + ) || '- No changes since the previous release.' + const document = createReleaseDocument(releaseVersion, changelog) + const {publicChangelog} = parseReleaseDocument(document, { + filename: identity.filename, + }) + return { + mode: 'dry-run', + identity, + sourceSha, + packageVersion, + expoVersion: expo.version, + runtimePolicy: expo.runtimeVersion.policy, + previousTag, + document, + publicChangelog, + steps: [ + `Create ${identity.branch} from ${sourceSha}.`, + `Commit ${identity.filename} to that branch. Its contents are included below.`, + `Create draft GitHub Release "${identity.githubReleaseName}" with tag ${identity.tag} and the public notes below.`, + 'Request production iOS, Android, and web builds from the prepared commit.', + 'After successful builds, record their actual build numbers and source commit in the release file.', + 'Leave the app release to a person.', + ], + notChecked: [ + 'Existing GitHub branches, tags, and releases are not checked for conflicts or safe reuse.', + 'Builds, credentials, store submissions, and deployment are not exercised.', + 'The prepared commit, build numbers, and final release file do not exist yet.', + 'Translations are not refreshed. Release notes are provisional commit titles.', + ], + } +} + +/** Format the same report for the terminal and the Actions summary. */ +export function renderReport(report) { + return [ + `# Release ${report.identity.version}: dry run`, + '', + 'Version checks passed. No release branch, commit, tag, GitHub Release, build, or deployment was created.', + '', + `- Source commit: ${report.sourceSha}`, + `- Package / Expo version: ${report.packageVersion} / ${report.expoVersion}`, + `- Runtime policy: ${report.runtimePolicy}`, + `- Previous release tag: ${report.previousTag ?? 'none (using all reachable history)'}`, + `- Release branch: ${report.identity.branch}`, + `- Release tag: ${report.identity.tag}`, + `- GitHub Release: ${report.identity.githubReleaseName}`, + '', + '## GitHub checks', + '', + ...(report.github + ? [ + `Result: ${report.github.status}. Checked at ${report.github.checkedAt}.`, + ...report.github.checks.map( + check => + `- ${check.resource}: **${check.action}** — ${check.detail}`, + ), + '', + 'Create/reuse describes a proposed action only. If any check is blocked, the whole preparation is blocked. These reads are a snapshot; a live run must recheck before writing.', + ] + : [ + 'GitHub has not been checked. Run check-github.mjs to inspect existing resources.', + ]), + '', + ...(report.execution + ? [ + '## Planned requests', + '', + `Dry run: ${report.execution.status}. No requests below were sent.`, + ...(report.execution.reason ? [report.execution.reason] : []), + ...(report.execution.warnings ?? []).map( + warning => `- Unverified: ${warning}`, + ), + '', + ...report.execution.steps.flatMap(step => [ + `### ${step.label}`, + '', + `Result: ${step.result ?? 'not reached'}.`, + ...(step.precondition + ? [`Precondition: ${step.precondition}`] + : []), + ...(step.checkedAt + ? [`GitHub rechecked at ${step.checkedAt}.`] + : []), + ...(step.dependsOn?.length + ? [`Depends on: ${step.dependsOn.join(', ')}.`] + : []), + '', + '
',
+            JSON.stringify(step.request ?? {reuse: step.value}, null, 2)
+              .replaceAll('&', '&')
+              .replaceAll('<', '<')
+              .replaceAll('>', '>'),
+            '
', + '', + ]), + ...(report.execution.notes ?? []).map(note => `- ${note}`), + '', + 'Still pending:', + ...report.execution.pending.map(item => `- ${item}`), + '', + ] + : []), + '## What a real run would do', + '', + ...report.steps.map((step, index) => `${index + 1}. ${step}`), + '', + '## Still unverified', + '', + ...report.notChecked + .filter(item => !report.github || !item.startsWith('Existing GitHub')) + .map(item => `- ${item}`), + '', + `## ${report.identity.filename}`, + '', + '
',
+    report.document
+      .replaceAll('&', '&')
+      .replaceAll('<', '<')
+      .replaceAll('>', '>'),
+    '
', + '', + '## Public release notes', + '', + report.publicChangelog, + '', + ].join('\n') +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + const [version, source, output, ...extra] = process.argv.slice(2) + if (!version || !source || extra.length) { + throw new Error( + 'Usage: node scripts/release/prepare.mjs VERSION SOURCE_DIRECTORY [NEW_OUTPUT_DIRECTORY]', + ) + } + const report = prepareRelease(source, version) + const markdown = renderReport(report) + if (output) { + mkdirSync(output) + for (const [name, content] of Object.entries({ + 'README.md': markdown, + 'report.json': JSON.stringify(report, null, 2) + '\n', + [report.identity.filename]: report.document, + 'github-release-body.md': report.publicChangelog + '\n', + })) + writeFileSync(join(output, name), content, {flag: 'wx'}) + } + process.stdout.write(markdown) + } catch (error) { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 + } +} diff --git a/scripts/release/prepare.test.js b/scripts/release/prepare.test.js new file mode 100644 index 000000000..c9fd1ddee --- /dev/null +++ b/scripts/release/prepare.test.js @@ -0,0 +1,116 @@ +/* oxlint-disable import/no-nodejs-modules -- This suite exercises a Node CLI with temporary Git repositories. */ +import {execFileSync, spawnSync} from 'node:child_process' +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join, resolve} from 'node:path' + +const cli = resolve('scripts/release/prepare.mjs') +let directory +let source + +function git(...args) { + return execFileSync('git', ['-C', source, ...args], {encoding: 'utf8'}).trim() +} + +function run(version = '1.2.3', output) { + return spawnSync( + process.execPath, + [cli, version, source, ...(output ? [output] : [])], + {encoding: 'utf8'}, + ) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'release-dry-run-')) + source = join(directory, 'source') + execFileSync('git', ['init', '--quiet', source]) + git('config', 'user.email', 'test@example.com') + git('config', 'user.name', 'Test') + git('config', 'commit.gpgsign', 'false') + git('config', 'tag.gpgsign', 'false') + git('config', 'core.hooksPath', '/dev/null') + writeFileSync(join(source, 'package.json'), '{"version":"1.2.3"}') + writeFileSync( + join(source, 'app.config.js'), + "module.exports = () => ({expo: {version: '1.2.3', runtimeVersion: {policy: 'appVersion'}}})", + ) + git('add', '.') + git('commit', '--quiet', '-m', 'Old change') + git('tag', '1.2.2') + writeFileSync(join(source, 'change.txt'), 'change') + git('add', '.') + git('commit', '--quiet', '-m', 'New change') +}) + +afterEach(() => rmSync(directory, {recursive: true, force: true})) + +test('reads real history and saves notes without changing source or refs; repeats safely', () => { + const before = [ + git('rev-parse', 'HEAD'), + git('show-ref'), + git('status', '--porcelain'), + ] + const output = join(directory, 'report') + const result = run('1.2.3', output) + expect(result.status).toBe(0) + expect(result.stdout).toContain(before[0]) + expect(result.stdout).toContain('release-1.2.3') + expect( + JSON.parse(readFileSync(join(output, 'report.json'), 'utf8')), + ).toMatchObject({ + mode: 'dry-run', + sourceSha: before[0], + previousTag: '1.2.2', + publicChangelog: '## Initial release\n\n- New change', + }) + expect( + readFileSync(join(output, 'github-release-body.md'), 'utf8'), + ).not.toContain('releaseVersion:') + expect(run().stdout).toBe(result.stdout) + expect(run('1.2.3', output).status).toBe(1) + expect([ + git('rev-parse', 'HEAD'), + git('show-ref'), + git('status', '--porcelain'), + ]).toEqual(before) +}) + +test('rejects invalid and mismatched versions before writing a report', () => { + expect(run('01.2.3').stderr).toContain('strict x.y.z') + expect(run('1.2.4').stderr).toContain('package version is 1.2.3') + expect(run('--apply').status).toBe(1) +}) + +test('rejects changed source instead of claiming it matches the commit', () => { + writeFileSync(join(source, 'change.txt'), 'uncommitted') + expect(run().stderr).toContain('tracked changes') +}) + +test('rejects a different runtime policy', () => { + writeFileSync( + join(source, 'app.config.js'), + "module.exports = () => ({expo: {version: '1.2.3', runtimeVersion: {policy: 'fingerprint'}}})", + ) + git('add', '.') + git('commit', '--quiet', '-m', 'Change runtime') + expect(run().stderr).toContain('runtimeVersion.policy') +}) + +test('uses reachable history when there is no previous version tag', () => { + git('tag', '-d', '1.2.2') + expect(run().stdout).toContain('- Old change') + expect(run().stdout).toContain('none (using all reachable history)') +}) + +test('rejects shallow history instead of generating incomplete notes', () => { + const clone = join(directory, 'shallow') + execFileSync('git', [ + 'clone', + '--quiet', + '--depth=1', + `file://${source}`, + clone, + ]) + source = clone + expect(run().stderr).toContain('Full Git history') +})