Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/workflows/TESTS/internal_on_merge_tag_versions.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { extractRunScript, readWorkflow, runScript } = require('./workflow-test-helpers');

const workflowFile = 'internal_on_merge_tag_versions.yml';

const gitEnv = {
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: '[email protected]',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: '[email protected]',
};

function git(cwd, args) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: { ...process.env, ...gitEnv },
});
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`);
}
return result.stdout.trim();
}

describe('internal_on_merge_tag_versions trigger', () => {
const workflow = readWorkflow(workflowFile);

test('runs only for pull requests into main', () => {
expect(workflow).toMatch(/pull_request:\n {4}branches: \[main\]\n {4}types: \[closed\]/);
});

test('tags the merge commit of the pull request', () => {
expect(workflow).toContain('ref: ${{ github.event.pull_request.merge_commit_sha }}');
});

test('runs steps in bash with pipefail, so a failed tag lookup fails the step', () => {
expect(workflow).toContain('defaults:\n run:\n shell: bash\n');
});
});

describe('internal_on_merge_tag_versions Create Version Tags', () => {
let directory;
let remote;
let checkout;

beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tag-versions-'));
remote = path.join(directory, 'remote.git');
checkout = path.join(directory, 'checkout');
git(directory, ['init', '--quiet', '--bare', remote]);
git(directory, ['init', '--quiet', checkout]);
fs.writeFileSync(path.join(checkout, 'file.txt'), 'first\n');
git(checkout, ['add', 'file.txt']);
git(checkout, ['commit', '--quiet', '-m', 'first']);
git(checkout, ['remote', 'add', 'origin', remote]);
git(checkout, ['tag', 'actions/existing/1.0.0']);
git(checkout, ['push', '--quiet', 'origin', 'refs/tags/actions/existing/1.0.0']);
git(checkout, ['tag', '--delete', 'actions/existing/1.0.0']);
fs.writeFileSync(path.join(checkout, 'file.txt'), 'second\n');
git(checkout, ['commit', '--quiet', '-am', 'second']);
});

afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});

const createTags = componentTags =>
runScript(extractRunScript(workflowFile, 'set-version-tags', 'Create Version Tags'), {
cwd: checkout,
env: { ...gitEnv, COMPONENT_TAGS: componentTags },
});

const remoteTags = () =>
git(checkout, ['ls-remote', '--tags', '--refs', 'origin'])
.split('\n')
.filter(Boolean)
.map(line => line.split('\t')[1].replace('refs/tags/', ''))
.sort();

test('pushes every tag on the checked-out commit', () => {
const result = createTags('actions/new-action/1.0.0,workflows/new_workflow/2.0.0');

expect(result.status).toBe(0);
expect(remoteTags()).toEqual([
'actions/existing/1.0.0',
'actions/new-action/1.0.0',
'workflows/new_workflow/2.0.0',
]);
const head = git(checkout, ['rev-parse', 'HEAD']);
expect(git(remote, ['rev-parse', 'workflows/new_workflow/2.0.0'])).toBe(head);
expect(result.summary).toContain('✅ Created and pushed tag: actions/new-action/1.0.0');
});

test('pushes no tags when any of them already exists', () => {
const result = createTags('actions/new-action/1.0.0,actions/existing/1.0.0');

expect(result.status).toBe(1);
expect(result.stdout).toContain(
'::error title=Tags Already Exist::These tags already exist, so no tags were created: actions/existing/1.0.0'
);
expect(remoteTags()).toEqual(['actions/existing/1.0.0']);
});

test('reports a rejected push and pushes no tags', () => {
const hook = path.join(remote, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });

const result = createTags('actions/new-action/1.0.0,workflows/new_workflow/2.0.0');

expect(result.status).toBe(1);
expect(result.stdout).toContain(
'::error title=Tag Push Rejected::The atomic push was rejected, so no tags were pushed.'
);
expect(result.summary).not.toContain('✅');
expect(remoteTags()).toEqual(['actions/existing/1.0.0']);
});

test('does nothing when there are no component tags', () => {
const result = createTags('');

expect(result.status).toBe(0);
expect(remoteTags()).toEqual(['actions/existing/1.0.0']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { extractRunScript, readWorkflow, runScript } = require('./workflow-test-helpers');

const workflowFile = 'internal_on_pr_validate_component_version.yml';

const gitEnv = {
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: '[email protected]',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: '[email protected]',
};

function git(cwd, args) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: { ...process.env, ...gitEnv },
});
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`);
}
return result.stdout.trim();
}

describe('internal_on_pr_validate_component_version shell', () => {
test('runs steps in bash with pipefail, so a failed tag lookup fails the step', () => {
expect(readWorkflow(workflowFile)).toContain('defaults:\n run:\n shell: bash\n');
});
});

describe('internal_on_pr_validate_component_version Get released version tags', () => {
let directory;
let checkout;

beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'released-tags-'));
const remote = path.join(directory, 'remote.git');
checkout = path.join(directory, 'checkout');
git(directory, ['init', '--quiet', '--bare', remote]);
git(directory, ['init', '--quiet', checkout]);
fs.writeFileSync(path.join(checkout, 'file.txt'), 'first\n');
git(checkout, ['add', 'file.txt']);
git(checkout, ['commit', '--quiet', '-m', 'first']);
git(checkout, ['remote', 'add', 'origin', remote]);
git(checkout, ['tag', 'actions/pr-open-check/1.0.0']);
git(checkout, ['tag', 'workflows/run_semgrep_scan/2.0.0']);
git(checkout, ['push', '--quiet', 'origin', '--tags']);
});

afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});

const getReleasedTags = () =>
runScript(
extractRunScript(workflowFile, 'validate-version-labels', 'Get released version tags'),
{ cwd: checkout, env: gitEnv }
);

test('writes the remote tag names to released_tags.txt', () => {
const result = getReleasedTags();

expect(result.status).toBe(0);
expect(fs.readFileSync(path.join(checkout, 'released_tags.txt'), 'utf8')).toBe(
'actions/pr-open-check/1.0.0\nworkflows/run_semgrep_scan/2.0.0\n'
);
});

test('fails when the remote cannot be read', () => {
git(checkout, ['remote', 'set-url', 'origin', path.join(directory, 'missing.git')]);

const result = getReleasedTags();

expect(result.status).not.toBe(0);
});
});
38 changes: 37 additions & 1 deletion .github/workflows/internal_on_merge_tag_versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ name: Set Version Tags

on:
pull_request:
branches: [main]
types: [closed]

permissions: {}

defaults:
run:
shell: bash

jobs:
no-merge:
if: github.event.pull_request.merged == false
Expand All @@ -27,12 +32,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: true # Required for git push of the version tags.

- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
package-manager-cache: false

- name: Get all labels from PR
id: get_labels
Expand Down Expand Up @@ -73,8 +80,37 @@ jobs:
COMPONENT_TAGS: ${{ steps.get_tags.outputs.componentTags }}
run: |
IFS=',' read -r -a tagsArray <<< "$COMPONENT_TAGS"
if [ "${#tagsArray[@]}" -eq 0 ]; then
exit 0
fi

released_tags=$(git ls-remote --tags --refs origin | cut -f2)
existing=()
refs=()
for tag in "${tagsArray[@]}"; do
if grep -Fxq "refs/tags/$tag" <<< "$released_tags"; then
existing+=("$tag")
fi
refs+=("refs/tags/$tag")
done

if [ "${#existing[@]}" -gt 0 ]; then
message="These tags already exist, so no tags were created: ${existing[*]}"
echo "❌ $message" >> "$GITHUB_STEP_SUMMARY"
echo "::error title=Tags Already Exist::$message"
exit 1
fi

for tag in "${tagsArray[@]}"; do
git tag "$tag"
git push origin "$tag"
done
if ! git push --atomic origin "${refs[@]}"; then
message="The atomic push was rejected, so no tags were pushed. Another run may have created one of these tags since the check: ${tagsArray[*]}"
echo "❌ $message" >> "$GITHUB_STEP_SUMMARY"
echo "::error title=Tag Push Rejected::$message"
exit 1
fi

for tag in "${tagsArray[@]}"; do
echo "✅ Created and pushed tag: $tag" >> "$GITHUB_STEP_SUMMARY"
done
25 changes: 23 additions & 2 deletions .github/workflows/internal_on_pr_validate_component_version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ on:
- unlabeled
- reopened

concurrency:
group: validate-version-labels-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions: {}

defaults:
run:
shell: bash

jobs:
validate-version-labels:
name: Validate PR Version Labels
Expand Down Expand Up @@ -83,13 +91,20 @@ jobs:

echo "$all_labels" > all_labels.txt

- name: Get released version tags
run: |
git ls-remote --tags --refs origin | cut -f2 | sed 's#^refs/tags/##' > released_tags.txt

- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
package-manager-cache: false

- name: Label Validation
id: validate
env:
RELEASED_TAGS_FILE: released_tags.txt
run: node scripts/internal-ci/validate-version-labels/index.js all_labels.txt

- name: Validation Job Summary
Expand All @@ -102,8 +117,10 @@ jobs:
INVALID_VERSION_LABELS: ${{ steps.validate.outputs.invalidVersionLabels }}
HAS_UNTRACKED_VERSION: ${{ steps.validate.outputs.hasUntrackedVersion }}
COMPONENT_VERSION_LABELS: ${{ steps.validate.outputs.componentVersionLabels }}
DUPLICATE_COMPONENTS: ${{ steps.validate.outputs.duplicateComponents }}
INVALID_COMPONENTS: ${{ steps.validate.outputs.invalidComponents }}
MISSING_CHANGELOGS: ${{ steps.validate.outputs.missingChangelogs }}
RELEASED_VERSIONS: ${{ steps.validate.outputs.releasedVersions }}
run: |
{
echo "## Validation Outcome"
Expand All @@ -125,12 +142,18 @@ jobs:
if [ -n "$INVALID_VERSION_LABELS" ]; then
echo "**Invalid version labels**: $INVALID_VERSION_LABELS"
fi
if [ -n "$DUPLICATE_COMPONENTS" ]; then
echo "**Components with more than one version label**: $DUPLICATE_COMPONENTS"
fi
if [ -n "$INVALID_COMPONENTS" ]; then
echo "**Nonexistent/Invalid components**: $INVALID_COMPONENTS"
fi
if [ -n "$MISSING_CHANGELOGS" ]; then
echo "**Components missing changelogs/entries**: $MISSING_CHANGELOGS"
fi
if [ -n "$RELEASED_VERSIONS" ]; then
echo "**Versions already released**: $RELEASED_VERSIONS"
fi
elif [ "$IS_VALID" == "true" ]; then
echo "✅ Version label validation passed"
fi
Expand All @@ -146,13 +169,11 @@ jobs:
HAS_UNTRACKED_VERSION: ${{ steps.validate.outputs.hasUntrackedVersion }}
VALID_COMPONENTS: ${{ steps.validate.outputs.validComponents }}
run: |
set -x
summary="## Tags\n"
if [ "${HAS_UNTRACKED_VERSION}" = "true" ]; then
summary+="- No Tags will be created on main"
else
summary+="The following tags will be created on main after merge\n\n"
echo "$VALID_COMPONENTS"
IFS=',' read -ra components <<< "$VALID_COMPONENTS"
for component_version in "${components[@]}"; do
summary+="🏷️ \`$component_version\`\n"
Expand Down
9 changes: 6 additions & 3 deletions VERSIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ v:a/pr-open-check/1.1.0
v:wf/run_semgrep_scan/1.0.0
```

Multiple version labels are allowed on a PR.
Multiple version labels are allowed on a PR, one per component.

### Special Case: Untracked Versions

Expand Down Expand Up @@ -109,7 +109,9 @@ A PR will fail automated validation if any of the following are true:
- There are multiple version labels and one of them is `v:untracked`
- A version label uses an invalid component name
- X.Y.Z version does not appear in the component’s CHANGELOG.md
- There are duplicate or malformed version labels
- A component has more than one version label
- A version label's tag already exists, so the version is already released
- There are malformed version labels

A PR cannot merge until version validation passes.

Expand Down Expand Up @@ -163,7 +165,8 @@ Each released version must have a heading that includes the version label

Minimum requirements:

- The header must contain `## X.Y.Z` exactly (this is what validation looks for).
- The header must be a line that is exactly `## X.Y.Z`, optionally followed by ` - title` or
` — title` (this is what validation looks for).
- The PR must add or update an entry for the version used in the label
(`v:a/{component-name}/X.Y.Z` or `v:wf/{component-name}/X.Y.Z`).

Expand Down
Loading
Loading