diff --git a/.github/scripts/publish-npm.js b/.github/scripts/publish-packages.js similarity index 80% rename from .github/scripts/publish-npm.js rename to .github/scripts/publish-packages.js index c0553b5..9fe711e 100644 --- a/.github/scripts/publish-npm.js +++ b/.github/scripts/publish-packages.js @@ -12,14 +12,39 @@ const VERSION = process.env.VERSION || ""; const dryRun = process.argv.includes("--dry-run"); const checkOnly = process.argv.includes("--check"); const resumeExisting = process.argv.includes("--resume-existing"); +const registryArg = process.argv.find((arg) => arg.startsWith("--registry=")); const unknownArgs = process.argv .slice(2) - .filter((arg) => !["--dry-run", "--check", "--resume-existing"].includes(arg)); + .filter( + (arg) => + !["--dry-run", "--check", "--resume-existing"].includes(arg) && + !arg.startsWith("--registry=") + ); + +const registries = { + npm: { + label: "npm", + url: "https://registry.npmjs.org/", + publishArgs: [], + }, + "github-packages": { + label: "GitHub Packages", + url: "https://npm.pkg.github.com/", + // Provenance is generated for npmjs.org publishes; GitHub Packages uses GITHUB_TOKEN auth. + publishArgs: ["--provenance=false"], + }, +}; +const registryName = registryArg ? registryArg.slice("--registry=".length) : "npm"; +const registry = registries[registryName]; if (unknownArgs.length > 0) { console.error(`Unknown argument(s): ${unknownArgs.join(", ")}`); process.exit(1); } +if (!registry) { + console.error(`Unknown registry: ${registryName}`); + process.exit(1); +} if (dryRun && checkOnly) { console.error("--dry-run and --check cannot be used together"); process.exit(1); @@ -58,10 +83,14 @@ function fail(message) { function npmView(packageName) { try { - const output = execFileSync("npm", ["view", `${packageName}@${version}`, "--json"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); + const output = execFileSync( + "npm", + ["view", `${packageName}@${version}`, "--json", "--registry", registry.url], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ).trim(); if (!output) { return null; } @@ -152,8 +181,9 @@ function publish(packageDir) { return; } const args = dryRun - ? ["publish", "--dry-run", "--access", "public"] - : ["publish", "--access", "public"]; + ? ["publish", "--dry-run", "--access", "public", "--registry", registry.url] + : ["publish", "--access", "public", "--registry", registry.url]; + args.push(...registry.publishArgs); execFileSync("npm", args, { cwd: packageDir, stdio: "inherit" }); } @@ -189,7 +219,7 @@ if (!dryRun && !checkOnly) { const remotePackage = npmView(pkg.name); if (remotePackage) { if (!resumeExisting) { - fail(`${pkg.name}@${version} already exists; use --resume-existing only after verifying recovery is intended`); + fail(`${pkg.name}@${version} already exists in ${registry.label}; use --resume-existing only after verifying recovery is intended`); } assertRemotePackageMatches(pkg.dir, pkg.name, remotePackage); existingPackages.set(pkg.name, true); @@ -199,7 +229,7 @@ if (!dryRun && !checkOnly) { for (const pkg of packages) { if (existingPackages.has(pkg.name)) { - console.log(`Skipping existing matching package ${pkg.name}@${version}`); + console.log(`Skipping existing matching ${registry.label} package ${pkg.name}@${version}`); continue; } publish(pkg.dir); diff --git a/.github/scripts/release-workflow.js b/.github/scripts/release-workflow.js index 1fa0534..9249530 100644 --- a/.github/scripts/release-workflow.js +++ b/.github/scripts/release-workflow.js @@ -33,17 +33,17 @@ function validateDispatch(env = process.env) { if (ctx.dryRun) { if (ctx.resumeExistingNpm) { - fail("resume_existing_npm is only valid for real npm publish recovery"); + fail("resume_existing_npm is only valid for real package publishing recovery"); } - if (ctx.githubRef !== "refs/heads/main") { - fail("release dry-run must be dispatched from refs/heads/main"); + if (!ctx.githubRef.startsWith("refs/heads/")) { + fail("release dry-run must be dispatched from a branch"); } return ctx; } if (ctx.resumeExistingNpm) { if (ctx.githubRef !== ctx.tagRef) { - fail(`npm publish recovery must be dispatched from ${ctx.tagRef}`); + fail(`package publishing recovery must be dispatched from ${ctx.tagRef}`); } return ctx; } @@ -200,7 +200,7 @@ function assertExistingRelease(env = process.env) { ]) ); } catch (err) { - fail(`resume_existing_npm requires an existing non-draft GitHub Release for ${ctx.tag}`); + fail(`package publishing recovery requires an existing non-draft GitHub Release for ${ctx.tag}`); } if (release.tagName !== ctx.tag) { @@ -210,7 +210,7 @@ function assertExistingRelease(env = process.env) { fail(`GitHub Release ${ctx.tag} is still a draft`); } - console.log(`Resuming npm publish after existing GitHub Release: ${release.url}`); + console.log(`Resuming package publishing after existing GitHub Release: ${release.url}`); return ctx; } diff --git a/.github/scripts/release-workflow.test.js b/.github/scripts/release-workflow.test.js index 5603fb9..94af68f 100644 --- a/.github/scripts/release-workflow.test.js +++ b/.github/scripts/release-workflow.test.js @@ -37,13 +37,19 @@ assert.doesNotThrow(() => GITHUB_REF: "refs/heads/main", })) ); +assert.doesNotThrow(() => + validateDispatch(env({ + DRY_RUN: "true", + GITHUB_REF: "refs/heads/codex/publish-github-packages", + })) +); assert.throws( () => validateDispatch(env({ DRY_RUN: "true", RESUME_EXISTING_NPM: "true" })), /resume_existing_npm/ ); assert.throws( () => validateDispatch(env({ DRY_RUN: "true", GITHUB_REF: "refs/tags/v1.2.3" })), - /dry-run must be dispatched/ + /dry-run must be dispatched from a branch/ ); assert.doesNotThrow(() => diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d63384..1b4c6bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ on: required: true type: string dry_run: - description: "Build and validate artifacts without publishing a GitHub Release or npm packages" + description: "Dry run: build and validate only" required: true default: true type: boolean resume_existing_npm: - description: "Resume npm publish by skipping already-published matching package versions" + description: "Skip already-published matching package versions" required: true default: false type: boolean @@ -124,12 +124,24 @@ jobs: - name: Validate package metadata env: VERSION: ${{ inputs.version }} - run: node .github/scripts/publish-npm.js --check + run: node .github/scripts/publish-packages.js --check - name: Validate npm publish command env: VERSION: ${{ inputs.version }} - run: node .github/scripts/publish-npm.js --dry-run + run: node .github/scripts/publish-packages.js --dry-run + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + registry-url: https://npm.pkg.github.com + scope: "@customerio" + + - name: Validate GitHub Packages publish command + env: + VERSION: ${{ inputs.version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/publish-packages.js --registry=github-packages --dry-run github_release: needs: validate_dispatch @@ -280,4 +292,58 @@ jobs: if [[ "$RESUME_EXISTING_NPM" == "true" ]]; then args+=(--resume-existing) fi - node .github/scripts/publish-npm.js "${args[@]}" + node .github/scripts/publish-packages.js "${args[@]}" + + github_packages_publish: + needs: npm_publish + if: >- + ${{ + always() && + !inputs.dry_run && + needs.npm_publish.result == 'success' + }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Assert checkout and tag ref + env: + VERSION_INPUT: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + run: node .github/scripts/release-workflow.js assert-tag-run + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + registry-url: https://npm.pkg.github.com + scope: "@customerio" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: goreleaser-dist + path: dist/ + + - name: Prepare npm packages + env: + VERSION: ${{ inputs.version }} + run: node .github/scripts/prepare-npm-packages.js + + - name: Publish GitHub Packages + env: + VERSION: ${{ inputs.version }} + RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + args=(--registry=github-packages) + if [[ "$RESUME_EXISTING_NPM" == "true" ]]; then + args+=(--resume-existing) + fi + node .github/scripts/publish-packages.js "${args[@]}"