diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 2df2802..7a885fe 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -3,10 +3,10 @@ # with the required `package` input; the workflow then, for THAT package only: # 1. Bumps the selected package's version in its own package.json # 2. Generates a changelog from git history since the package's last release tag -# 3. Updates the package's changelog file and creates a release branch + package-scoped tag +# 3. Updates the package's changelog file and creates a release branch # Packages are versioned and tagged independently (tag prefixes: v, azuremanaged-v, durable-functions-v). -# After the workflow completes, manually open a PR from the release branch to main, then publish -# from the package directory using the npm command shown in the run summary (mind the dist-tag). +# After the workflow completes, manually open and merge a PR from the release branch to main. +# Only then create the package-scoped tag on the exact merged commit and publish. name: Prepare Release @@ -122,16 +122,36 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "New version: $NEW_VERSION" + - name: Verify target release tag is unused + id: target-tag + env: + NEW_VERSION: ${{ steps.calc-version.outputs.new_version }} + TAG_PREFIX: ${{ steps.resolve-package.outputs.tag_prefix }} + run: | + TAG_NAME="${TAG_PREFIX}${NEW_VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/${TAG_NAME}" >/dev/null; then + echo "::error::Release tag ${TAG_NAME} already exists on origin. Refusing to prepare another release for an immutable tag." + exit 1 + else + STATUS=$? + if [ "$STATUS" -ne 2 ]; then + echo "::error::Could not verify whether release tag ${TAG_NAME} exists on origin." + exit "$STATUS" + fi + fi + echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "Verified release tag ${TAG_NAME} does not exist on origin" + - name: Get latest release tag id: get-latest-tag env: PACKAGE: ${{ github.event.inputs.package }} CURRENT_VERSION: ${{ steps.get-current-version.outputs.current_version }} - NEW_VERSION: ${{ steps.calc-version.outputs.new_version }} TAG_PREFIX: ${{ steps.resolve-package.outputs.tag_prefix }} + TARGET_TAG: ${{ steps.target-tag.outputs.tag_name }} run: | - # Get the latest tag for THIS package (its tag prefix), excluding the tag being - # created so that re-runs don't use it as the baseline. The legacy core prefix "v" + # Get the latest tag for THIS package (its tag prefix), excluding the target release + # tag as a defense against it appearing after the validation step. The legacy core prefix "v" # still matches historical tags like v0.3.0 (intended, backward compatible) and never # matches the "azuremanaged-v"/"durable-functions-v" namespaces (they start with a/d, not v). # Without versionsort.suffix=-, git ranks a prerelease (e.g. X-beta.1) ABOVE its own GA @@ -139,7 +159,7 @@ jobs: # re-list every commit already shipped in GA. Treating "-" as the prerelease separator # orders X-beta.1 below X. LATEST_TAG=$(git -c versionsort.suffix=- tag -l "${TAG_PREFIX}*" --sort=-v:refname | \ - grep -v "^${TAG_PREFIX}${NEW_VERSION}$" | head -n 1) + grep -Fvx -- "$TARGET_TAG" | head -n 1) if [ -z "$LATEST_TAG" ]; then LEGACY_TAG="v${CURRENT_VERSION}" if [ "$PACKAGE" = "durabletask-js-azuremanaged" ] && \ @@ -253,16 +273,15 @@ jobs: NEW_VERSION="${{ steps.calc-version.outputs.new_version }}" PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}" NPM_NAME="${{ steps.resolve-package.outputs.npm_name }}" - TAG_PREFIX="${{ steps.resolve-package.outputs.tag_prefix }}" CHANGELOG_PATH="${{ steps.resolve-package.outputs.changelog_path }}" - TAG_NAME="${TAG_PREFIX}${NEW_VERSION}" + TAG_NAME="${{ steps.target-tag.outputs.tag_name }}" BRANCH_NAME="release/${TAG_NAME}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # Create and checkout release branch (idempotent) - git checkout -B "$BRANCH_NAME" + # Create and checkout a new release branch. + git checkout -b "$BRANCH_NAME" # Stage and commit changes (only the released package, lockfile, and its changelog) git add "${PKG_DIR}/package.json" @@ -270,16 +289,13 @@ jobs: git add "${CHANGELOG_PATH}" git commit -m "Release ${NPM_NAME}@${NEW_VERSION}" - # Create release tag (idempotent) - git tag -f "$TAG_NAME" - - # Push branch and tag (force to handle re-runs) - git push -f origin "$BRANCH_NAME" - git push -f origin "$TAG_NAME" + # Never overwrite an existing release branch. A conflicting re-run must fail so its + # version and changelog changes can be reviewed instead of replacing remote history. + git push origin "$BRANCH_NAME" echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - echo "Created branch $BRANCH_NAME and tag $TAG_NAME" + echo "Created branch $BRANCH_NAME; create tag $TAG_NAME only after the PR merges" - name: Summary run: | @@ -302,7 +318,7 @@ jobs: echo "- **Package**: ${NPM_NAME}" >> $GITHUB_STEP_SUMMARY echo "- **Version**: ${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY echo "- **Branch**: ${BRANCH_NAME}" >> $GITHUB_STEP_SUMMARY - echo "- **Tag**: ${TAG_NAME}" >> $GITHUB_STEP_SUMMARY + echo "- **Post-merge tag**: ${TAG_NAME} (not created by this workflow)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Next Step: Create a Pull Request" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -310,9 +326,29 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "[Create PR](https://github.com/microsoft/durabletask-js/compare/main...${BRANCH_NAME}?expand=1&title=Release+${NPM_NAME}@${NEW_VERSION})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "### After Merge: Tag the Exact Merged Commit" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Replace \`1234\` with the merged release PR number. These commands deliberately omit \`-f\`: if **${TAG_NAME}** already exists, stop and investigate; never move an existing release tag." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + echo "set -euo pipefail" >> $GITHUB_STEP_SUMMARY + echo "git switch main" >> $GITHUB_STEP_SUMMARY + echo "git pull --ff-only origin main" >> $GITHUB_STEP_SUMMARY + echo "git fetch origin --tags" >> $GITHUB_STEP_SUMMARY + echo "PR_NUMBER=1234 # replace with the merged release PR number" >> $GITHUB_STEP_SUMMARY + echo "MERGED_COMMIT=\$(gh pr view \"\$PR_NUMBER\" --repo microsoft/durabletask-js --json mergeCommit --jq '.mergeCommit.oid')" >> $GITHUB_STEP_SUMMARY + echo "git merge-base --is-ancestor \"\$MERGED_COMMIT\" origin/main" >> $GITHUB_STEP_SUMMARY + echo "test \"\$(git show \"\${MERGED_COMMIT}:${PKG_DIR}/package.json\" | node -p \"JSON.parse(require('fs').readFileSync(0, 'utf8')).version\")\" = \"${NEW_VERSION}\"" >> $GITHUB_STEP_SUMMARY + echo "if git rev-parse --verify --quiet \"refs/tags/${TAG_NAME}\" >/dev/null; then echo \"Tag ${TAG_NAME} already exists; refusing to move it.\" >&2; exit 1; fi" >> $GITHUB_STEP_SUMMARY + echo "git tag \"${TAG_NAME}\" \"\$MERGED_COMMIT\"" >> $GITHUB_STEP_SUMMARY + echo "git push origin \"refs/tags/${TAG_NAME}\"" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Pushing **${TAG_NAME}** triggers the code mirror's dedicated tag path. Before continuing, verify that run mirrored **${TAG_NAME}** to exactly \`\$MERGED_COMMIT\` and re-locked the internal tag. Then use the official \`main\` build whose source commit is exactly \`\$MERGED_COMMIT\`; never substitute a later \`main\` build." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "### Then Publish" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "After the release PR is merged, publish from the package directory:" >> $GITHUB_STEP_SUMMARY + echo "Use the ADO official build + ESRP release pipeline. If a maintainer must use the documented manual fallback, publish from the package directory:" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "cd ${PKG_DIR}" >> $GITHUB_STEP_SUMMARY echo "${PUBLISH_CMD}" >> $GITHUB_STEP_SUMMARY diff --git a/doc/release_process.md b/doc/release_process.md index 4bccad1..7d02318 100644 --- a/doc/release_process.md +++ b/doc/release_process.md @@ -59,15 +59,16 @@ The changelog step lists commits since the released package's **last package-sco For the **one package you selected** (and only that package): 1. **Determines the next version**: uses the `version` input, or auto-increments the selected package's current version -2. **Generates a changelog**: lists commits since that package's last release tag, scoped to the package's directory (`git log ..HEAD -- `), so only commits that touched that package are included -3. **Bumps the version**: updates `version` in that package's own `package.json` -4. **Updates that package's changelog**: core writes `CHANGELOG.md`, Azure Managed writes `packages/durabletask-js-azuremanaged/CHANGELOG.md`, and `durable-functions` writes `packages/azure-functions-durable/CHANGELOG.md` -5. **Creates a release branch and a package-scoped tag**: tag `` and branch `release/`, where the prefix is `v` (core), `azuremanaged-v`, or `durable-functions-v` -6. **For `durable-functions` only**: verifies the exact-pinned `@microsoft/durabletask-js` version is already published on public npm, and fails the run if it is not (guards the uninstallable-dependency case) +2. **Verifies the target tag is unused**: fails before preparing the release if the package-scoped tag already exists on `origin`; existing release tags are immutable +3. **Generates a changelog**: lists commits since that package's last release tag, scoped to the package's directory (`git log ..HEAD -- `), so only commits that touched that package are included +4. **Bumps the version**: updates `version` in that package's own `package.json` +5. **Updates that package's changelog**: core writes `CHANGELOG.md`, Azure Managed writes `packages/durabletask-js-azuremanaged/CHANGELOG.md`, and `durable-functions` writes `packages/azure-functions-durable/CHANGELOG.md` +6. **Creates a release branch**: branch `release/`, where the target tag prefix is `v` (core), `azuremanaged-v`, or `durable-functions-v`. The workflow does not create or modify the tag. +7. **For `durable-functions` only**: verifies the exact-pinned `@microsoft/durabletask-js` version is already published on public npm, and fails the run if it is not (guards the uninstallable-dependency case) ### After the Workflow Completes -The workflow only **prepares** a release — it bumps the version, generates the changelog, and creates the release branch and package-scoped tag. It does **not** publish to npm. Its run summary includes a **Create PR** link and an `npm publish` command; that command is a **manual fallback** for maintainers, and the sanctioned publish path is the ADO official build + ESRP release pipeline (see **Publishing** below). +The workflow only **prepares** a release — it bumps the version, generates the changelog, and creates the release branch. It does **not** create or modify a Git tag and does **not** publish to npm. Its run summary includes a **Create PR** link, safe post-merge tagging instructions, and an `npm publish` command; that publish command is a **manual fallback** for maintainers, and the sanctioned publish path is the ADO official build + ESRP release pipeline (see **Publishing** below). You must **manually create a pull request** from the release branch to `main`. The branch is `release/` where `` is the package-scoped tag — e.g. `release/durable-functions-v4.0.0-beta.1`, `release/azuremanaged-v0.3.0`, or `release/v0.4.0`: @@ -76,34 +77,69 @@ You must **manually create a pull request** from the release branch to `main`. T 3. Review the version bump and changelog update — only the released package should change 4. Merge the PR after CI passes -After the PR is merged, follow the **Publishing** steps below to build and publish. +After the PR is merged, follow the **Publishing** steps below. The package-scoped tag must be created only after merge and must point to the exact merged commit on `main`. ## Publishing (After Release PR is Merged) After the release PR is merged to `main`, follow these steps to build, sign, and publish the packages. -### Step 1: Run the Code Mirror Pipeline +### Step 0: Tag the Exact Merged Commit -Manually trigger the code mirror pipeline to sync the release to the internal ADO repo: +Sync the latest `main`, identify the merged release PR's exact commit, and verify both that it is on `main` and that it contains the expected package version. Then create and push the package-scoped tag without force. Replace the example values before running: + +```bash +set -euo pipefail +git switch main +git pull --ff-only origin main +git fetch origin --tags + +PR_NUMBER=1234 # replace with the merged release PR number +PKG_DIR="packages/durabletask-js-azuremanaged" # replace with the released package directory +NEW_VERSION="0.4.0" # replace with the released version +TAG_NAME="azuremanaged-v0.4.0" # replace with the package-scoped tag + +MERGED_COMMIT=$(gh pr view "$PR_NUMBER" --repo microsoft/durabletask-js --json mergeCommit --jq '.mergeCommit.oid') +git merge-base --is-ancestor "$MERGED_COMMIT" origin/main +test "$(git show "${MERGED_COMMIT}:${PKG_DIR}/package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" = "$NEW_VERSION" + +if git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then + echo "Tag ${TAG_NAME} already exists; refusing to move it." >&2 + exit 1 +fi +git tag "$TAG_NAME" "$MERGED_COMMIT" +git push origin "refs/tags/${TAG_NAME}" +``` + +Both tag commands deliberately omit `-f`. Fetching tags makes the explicit check fail when the tag already exists locally or on `origin`; the non-force push also rejects a tag created remotely in the meantime. **Never delete or force-move an existing release tag.** Stop and investigate any conflict. + +### Step 1: Confirm the Tag-Triggered Code Mirror + +Pushing the post-merge package tag in Step 0 automatically triggers the code mirror pipeline. The pipeline keeps its existing `main` branch trigger and also triggers for every tag, so a new release tag is mirrored immediately rather than waiting for a later `main` push: **Pipeline**: [durabletask-js code mirror](https://dev.azure.com/azfunc/internal/_build?definitionId=1757) -Run it on the `main` branch (which now contains the release commit). +Before continuing to the official build, verify all of the following in the tag-triggered run: + +1. Its source ref is the new package-scoped tag. +2. The mirrored internal tag resolves to exactly `MERGED_COMMIT` from Step 0. +3. The internal tag is locked after the mirror completes. + +Do not substitute a manual `main` run for this step: the shared engineering template routes tag refs through `ci/internal/code-mirror-tag.yml`, which compares the destination tag, deletes and recreates it with the mirror identity when required, and re-locks it. A `main` run uses the normal branch mirror path and pushes tags with the branch, so it does not provide the dedicated tag handling. The shared tag path operates on the internal mirror; it does not move or delete the immutable source tag on GitHub. ### Step 2: Run the Official Build Pipeline -Trigger the official build pipeline to produce signed `.tgz` artifacts. The official build (`eng/ci/official-build.yml`) triggers on and builds **`main`** — it has no branch/tag selector and never builds the short-lived release branch. Use (or pick the automatic run of) the official build whose source commit **includes the merged release commit(s)**: +Trigger the official build pipeline to produce signed `.tgz` artifacts. The official build (`eng/ci/official-build.yml`) triggers on and builds **`main`** — it has no branch/tag selector and never builds the short-lived release branch. Use the official build whose source commit is **exactly the merged release commit tagged in Step 0**, not merely a later commit that includes it: **Pipeline**: [durabletask-js.official](https://dev.azure.com/azfunc/internal/_build?definitionId=1012&_a=summary) -1. Click **Run pipeline** on `main` (or locate the `main` run that already contains the release commit) +1. Locate the automatic `main` run whose source version exactly matches `MERGED_COMMIT` / `TAG_NAME` from Step 0. If no exact run is available, stop and coordinate an exact-commit build with the pipeline owners; do not substitute a later `main` build. 2. Wait for it to complete 3. A single `main` build packs **all three** packages into one `drop` artifact; which single package publishes is decided later by the `package` parameter in the release pipeline (Step 3). Verify `drop` contains the correctly versioned `.tgz` files: - `buildoutputs/durabletask-js/microsoft-durabletask-js-X.Y.Z.tgz` - `buildoutputs/durabletask-js-azuremanaged/microsoft-durabletask-js-azuremanaged-X.Y.Z.tgz` - `buildoutputs/azure-functions-durable/durable-functions-X.Y.Z.tgz` -Never publish from the release branch — it exists only to carry the version/changelog PR into `main`. +Never publish from the release branch — it exists only to carry the version/changelog PR into `main`. The official build, merged commit, and package-scoped tag must all identify the same commit. ### Step 3: Run the Release Pipeline @@ -116,7 +152,7 @@ Trigger the release pipeline to publish one signed package to npm via ESRP. **Th **Pipeline**: [durabletask-js.release](https://dev.azure.com/azfunc/internal/_build?definitionId=1686) 1. Click **Run pipeline** -2. Select the **`main` official build from Step 2** (the one containing the release commit) as the source pipeline artifact +2. Select the **`main` official build from Step 2** (the one whose source commit exactly equals the tagged merged release commit) as the source pipeline artifact 3. Verify or change the **`package`** parameter to the one package to publish. Exactly one release stage is compiled and exactly one package is published; do not use queue-time stage selection to choose packages. 4. Approve the ESRP release when prompted. @@ -141,7 +177,7 @@ npm view durable-functions dist-tags Go to [GitHub Releases](https://github.com/microsoft/durabletask-js/releases) and create a new release: -- **Tag**: the package-scoped tag created by the release — e.g. `durable-functions-v4.0.0-beta.1`, `azuremanaged-v0.3.0`, or `v0.4.0` +- **Tag**: the package-scoped tag created on the exact merged commit in Step 0 — e.g. `durable-functions-v4.0.0-beta.1`, `azuremanaged-v0.3.0`, or `v0.4.0` - **Title**: the same tag, or `@` - **Description**: copy the relevant section from that package's changelog: `CHANGELOG.md` for core, `packages/durabletask-js-azuremanaged/CHANGELOG.md` for Azure Managed, or `packages/azure-functions-durable/CHANGELOG.md` for `durable-functions` - **Pre-release**: check this box for alpha/beta/rc/preview releases @@ -221,17 +257,9 @@ Create a release branch named `release/`, where `` is the package-scop ### 6. Merge and Tag -After the PR is approved and merged to `main`, create the package-scoped tag (``): - -```bash -git checkout main -git pull -# e.g. durable-functions-v4.0.0-beta.1, azuremanaged-v0.3.0, or v0.4.0 -git tag -git push origin -``` +After the PR is approved and merged to `main`, follow **Publishing Step 0** to sync `main`, identify and verify the exact merged commit, and create the package-scoped tag (``) without force. If the tag already exists, stop; never delete or move it. -Then follow the **Publishing** steps above (Steps 1-5). +Then continue with **Publishing** Steps 1-5. ## Quick Reference: npm Dist Tags diff --git a/eng/ci/code-mirror.yml b/eng/ci/code-mirror.yml index c148952..1acf950 100644 --- a/eng/ci/code-mirror.yml +++ b/eng/ci/code-mirror.yml @@ -4,6 +4,9 @@ trigger: # Below branches are examples for Azure/azure-functions-host. Replace with appropriate branches for your repository. # Keep this set limited as appropriate (don't mirror individual user branches). - main + tags: + include: + - '*' resources: repositories: