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
27 changes: 2 additions & 25 deletions .github/workflows/end-to-end.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,8 @@ env:
RELEASE_TAG: 0.0.0-fake+run_${{ github.run_id }}

jobs:
create-test-release:
runs-on: ubuntu-latest
permissions:
# contents:write is required to upload to the release assets
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-tags: 'true'
ref: ${{ github.ref }}

- name: Create a fake tag for the build
run: |
git config --global user.email "Fake User"
git config --global user.name "fake-user@example.com"
git tag -a -m "test tag for CI build ${{ github.run_id }}" ${{ env.RELEASE_TAG }}
git push origin ${{ env.RELEASE_TAG }}

- name: Create a fake release for the build
run: gh release create "${{ env.RELEASE_TAG }}" --title "${{ env.RELEASE_TAG }}" --draft --notes-from-tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

test-action:
runs-on: ${{ matrix.operating-system }}
needs: [create-test-release]
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -89,6 +65,7 @@ jobs:
with:
configure-flags: '--with-hello-name=FROM_MY_ACTION --enable-example-pie-extension'
release-tag: ${{ env.RELEASE_TAG }}
create-release: 'true'
github-token: ${{ secrets.GITHUB_TOKEN }}
upload-artifacts: 'true'

Expand All @@ -107,7 +84,7 @@ jobs:
- name: Delete test release
run: |
if gh release view "${{ env.RELEASE_TAG }}" > /dev/null 2>&1; then
gh release delete "${{ env.RELEASE_TAG }}" --yes --cleanup-tag
gh release delete "${{ env.RELEASE_TAG }}" --yes
else
echo "Release ${{ env.RELEASE_TAG }} does not exist, skipping deletion."
fi
Expand Down
49 changes: 17 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ with:

### Inputs

| Name | Description | Required | Default |
|--------------------|-------------------------------------------------------------------------------------------------|----------|-----------|
| `release-tag` | The tag to use when building the extension; there must be an existing draft release for the tag | `true` | - |
| `github-token` | The GitHub token to use. Usually `${{ secrets.GITHUB_TOKEN }}` would be fine for most cases. | `true` | - |
| `configure-flags` | If you need to pass additional flags to the `./configure` command, specify them here | `false` | `''` |
| `build-path` | Path to the extension source directory containing `config.m4`, relative to repo root | `false` | `'.'` |
| `upload-artifacts` | Whether to upload the generated `.zip` as a workflow build artifact | `false` | `'false'` |
| Name | Description | Required | Default |
|--------------------|--------------------------------------------------------------------------------------------------|----------|-----------|
| `release-tag` | The tag to use when building the extension; there must be an existing draft release for the tag | `true` | - |
| `create-release` | Create the release (as a draft) for `release-tag` if it doesn't already exist | `false` | `'false'` |
| `github-token` | The GitHub token to use. Usually `${{ secrets.GITHUB_TOKEN }}` would be fine for most cases. | `true` | - |
| `configure-flags` | If you need to pass additional flags to the `./configure` command, specify them here | `false` | `''` |
| `build-path` | Path to the extension source directory containing `config.m4`, relative to repo root | `false` | `'.'` |
| `upload-artifacts` | Whether to upload the generated `.zip` as a workflow build artifact | `false` | `'false'` |

### Outputs

Expand All @@ -50,7 +51,10 @@ with:
This use case is for a scenario where:

- The action triggers when you push any tag
- It will create a draft release
- It will build for a matrix of PHP versions, architectures and thread-safety modes,
creating the draft release the first time it's needed (it's safe for every matrix
job to do this - only one release is ever created, even if many jobs race to create
it at the same time)
- It will then check out your extension, set up the required PHP version, build it, and upload to the draft release

You would then have to navigate to the draft release and publish it.
Expand All @@ -67,27 +71,7 @@ permissions:
contents: read

jobs:
# This first step will create a *draft* release based on the tag name. In the
# case where immutable releases are enabled, the release MUST be in draft
# mode, otherwise we would not be able to attach the release assets (since
# the release is immutable once published.
create-draft-release:
runs-on: ubuntu-latest
permissions:
# contents:write is required to create the draft release
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-tags: 'true'
ref: ${{ github.ref }}
- name: Create draft release from tag
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${{ github.ref_name }}" --title "${{ github.ref_name }}" --draft --notes-from-tag

add-pie-binaries:
needs: [ create-draft-release ]
runs-on: ${{ matrix.operating-system }}
# The matrix defines which combination of binaries you want to build
strategy:
Expand All @@ -103,7 +87,7 @@ jobs:
- ts
- nts
permissions:
# contents:write is required to upload to the release assets
# contents:write is required to create the release and upload the release assets
contents: write
steps:
- name: Checkout
Expand All @@ -117,13 +101,14 @@ jobs:
env:
phpts: ${{ matrix.zts-mode }}

# Finally, this invokes the action, which builds the extension, creates
# the archive with the correct naming, and uploads it to the release for
# the given tag name
# This invokes the action, which builds the extension, creates the archive with
# the correct naming, creates the draft release for the tag if it doesn't already
# exist, and uploads it to the release for the given tag name
- name: Build and release
id: php-ext-binary-builder
uses: php/pie-ext-binary-builder@0.0.2
with:
release-tag: ${{ github.ref_name }}
create-release: 'true'
github-token: ${{ secrets.GITHUB_TOKEN }}
```
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ inputs:
release-tag:
description: 'The tag to use for the release'
required: true
create-release:
description: 'Create the release (as a draft) if it does not already exist for the release-tag'
required: false
default: 'false'
github-token:
description: 'The GitHub token to use for uploading assets'
required: true
Expand Down
169 changes: 163 additions & 6 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78267,7 +78267,9 @@ var __webpack_exports__ = {};
// EXPORTS
__nccwpck_require__.d(__webpack_exports__, {
yw: () => (/* binding */ buildExtension),
YG: () => (/* binding */ createRelease),
Ay: () => (/* binding */ src_0),
_m: () => (/* binding */ deleteReleaseLock),
fj: () => (/* binding */ determineArchitecture),
aS: () => (/* binding */ determineExtensionNameFromComposerJson),
vi: () => (/* binding */ determineLibcFlavour),
Expand All @@ -78277,7 +78279,10 @@ __nccwpck_require__.d(__webpack_exports__, {
_2: () => (/* binding */ determinePhpVersionFromPhpConfig),
v_: () => (/* binding */ determineZendThreadSafeMode),
_E: () => (/* binding */ extensionDetails),
dv: () => (/* binding */ findRelease),
Rd: () => (/* binding */ getReleaseNotesFromTag),
iW: () => (/* binding */ main),
yy: () => (/* binding */ sleep),
Yq: () => (/* binding */ uploadBuildArtifact),
fC: () => (/* binding */ uploadReleaseAsset)
});
Expand Down Expand Up @@ -138397,6 +138402,140 @@ async function uploadBuildArtifact(packageFilename) {
await artifact.uploadArtifact(packageFilename, [packageFilename], ".");
}

async function findRelease(octokit, owner, repo, releaseTag) {
const { data: releases } = await octokit.rest.repos.listReleases({
owner,
repo,
});

return releases.find(r => r.tag_name === releaseTag);
}

async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

const RELEASE_LOCK_POLL_INTERVAL_MS = 2000;
const RELEASE_LOCK_MAX_POLL_ATTEMPTS = 30;

// Simulate `--notes-from-tag` flag since the octokit API doesn't have an equivalent
async function getReleaseNotesFromTag(octokit, owner, repo, releaseTag) {
let ref;
try {
({ data: ref } = await octokit.rest.git.getRef({
owner,
repo,
ref: `tags/${releaseTag}`,
}));
} catch (err) {
if (err.status !== 404) {
throw err;
}
return '';
}

if (ref.object.type === 'tag') {
const { data: tagObject } = await octokit.rest.git.getTag({
owner,
repo,
tag_sha: ref.object.sha,
});
return tagObject.message;
}

const { data: commit } = await octokit.rest.git.getCommit({
owner,
repo,
commit_sha: ref.object.sha,
});
return commit.message;
}

async function deleteReleaseLock(octokit, owner, repo, releaseTag) {
try {
await octokit.rest.git.deleteRef({
owner,
repo,
ref: `pie-release-lock/${releaseTag}`,
});
} catch (err) {
warning(`Failed to release lock for tag: ${releaseTag} after a failed release creation: ${err.message}`);
}
}

// Create the release, but use a ref locking approach to avoid TOCTOU race
// conditions that would result in multiple duplicate draft releases being
// created.
async function createRelease(releaseTag) {
const githubToken = getInput("github-token");
const octokit = getOctokit(githubToken);
const { owner, repo } = github_context.repo;

info(`Checking whether a release already exists for tag: ${releaseTag}...`);
if (await src_action.findRelease(octokit, owner, repo, releaseTag)) {
info(`Release already exists for tag: ${releaseTag}, skipping creation.`);
return;
}

const lockRef = `refs/pie-release-lock/${releaseTag}`;
info(`Release not found for tag: ${releaseTag}. Attempting to acquire lock (${lockRef}) to create it...`);

let lockAcquired = false;
try {
await octokit.rest.git.createRef({
owner,
repo,
ref: lockRef,
sha: github_context.sha,
});
lockAcquired = true;
} catch (err) {
if (err.status !== 422) {
throw err;
}
info("Lock is already held by another job, will wait for the release to be created...");
}

if (lockAcquired) {
info(`Lock acquired, creating release for tag: ${releaseTag}...`);
try {
const notes = await src_action.getReleaseNotesFromTag(octokit, owner, repo, releaseTag);
await octokit.rest.repos.createRelease({
owner,
repo,
tag_name: releaseTag,
name: releaseTag,
draft: true,
...(notes ? { body: notes } : { generate_release_notes: true }),
});
} catch (err) {
if (!(await src_action.findRelease(octokit, owner, repo, releaseTag))) {
await src_action.deleteReleaseLock(octokit, owner, repo, releaseTag);
}
throw err;
}

info(`Release created for tag: ${releaseTag}.`);
return;
}

for (let attempt = 1; attempt <= RELEASE_LOCK_MAX_POLL_ATTEMPTS; attempt++) {
await src_action.sleep(RELEASE_LOCK_POLL_INTERVAL_MS);

if (await src_action.findRelease(octokit, owner, repo, releaseTag)) {
info(`Release for tag: ${releaseTag} has now been created by another job.`);
return;
}

info(`Still waiting for release to be created for tag: ${releaseTag} (attempt ${attempt}/${RELEASE_LOCK_MAX_POLL_ATTEMPTS})...`);
}

throw new Error(`Timed out waiting for release to be created for tag: ${releaseTag}`);
}

const RELEASE_VISIBILITY_POLL_INTERVAL_MS = 1000;
const RELEASE_VISIBILITY_MAX_POLL_ATTEMPTS = 10;

async function uploadReleaseAsset(releaseTag, packageFilename) {
info("Uploading release asset...");
const githubToken = getInput("github-token");
Expand All @@ -138405,12 +138544,17 @@ async function uploadReleaseAsset(releaseTag, packageFilename) {
const { owner, repo } = github_context.repo;

info(`Searching for release with tag: ${releaseTag} (including drafts)...`);
const { data: releases } = await octokit.rest.repos.listReleases({
owner,
repo,
});
let release = await src_action.findRelease(octokit, owner, repo, releaseTag);

// A release that was just created (e.g. by createRelease) can take a moment to become
// visible via listReleases, even to the job that just created it - so don't fail on
// the first miss.
for (let attempt = 1; !release && attempt <= RELEASE_VISIBILITY_MAX_POLL_ATTEMPTS; attempt++) {
info(`Release not visible yet for tag: ${releaseTag}, retrying (attempt ${attempt}/${RELEASE_VISIBILITY_MAX_POLL_ATTEMPTS})...`);
await src_action.sleep(RELEASE_VISIBILITY_POLL_INTERVAL_MS);
release = await src_action.findRelease(octokit, owner, repo, releaseTag);
}

const release = releases.find(r => r.tag_name === releaseTag);
if (!release) {
throw new Error(`No release found for tag: ${releaseTag}`);
}
Expand Down Expand Up @@ -138456,6 +138600,9 @@ async function main() {

await exec_exec("zip", ["-j", extPackageName, external_path_.join(modulesDir, extSoFile)]);

if (getBooleanInput("create-release")) {
await src_action.createRelease(releaseTag);
}
if (getBooleanInput("upload-artifacts")) {
await src_action.uploadBuildArtifact(extPackageName);
}
Expand All @@ -138475,6 +138622,11 @@ const src_action = {
determinePhpDebugMode,
determineZendThreadSafeMode,
uploadBuildArtifact,
findRelease,
sleep,
getReleaseNotesFromTag,
deleteReleaseLock,
createRelease,
uploadReleaseAsset,
extensionDetails,
main,
Expand All @@ -138488,7 +138640,9 @@ if (process.argv[1] === (0,external_url_.fileURLToPath)(import.meta.url)) {
}

var __webpack_exports__buildExtension = __webpack_exports__.yw;
var __webpack_exports__createRelease = __webpack_exports__.YG;
var __webpack_exports__default = __webpack_exports__.Ay;
var __webpack_exports__deleteReleaseLock = __webpack_exports__._m;
var __webpack_exports__determineArchitecture = __webpack_exports__.fj;
var __webpack_exports__determineExtensionNameFromComposerJson = __webpack_exports__.aS;
var __webpack_exports__determineLibcFlavour = __webpack_exports__.vi;
Expand All @@ -138498,9 +138652,12 @@ var __webpack_exports__determinePhpDebugMode = __webpack_exports__.qD;
var __webpack_exports__determinePhpVersionFromPhpConfig = __webpack_exports__._2;
var __webpack_exports__determineZendThreadSafeMode = __webpack_exports__.v_;
var __webpack_exports__extensionDetails = __webpack_exports__._E;
var __webpack_exports__findRelease = __webpack_exports__.dv;
var __webpack_exports__getReleaseNotesFromTag = __webpack_exports__.Rd;
var __webpack_exports__main = __webpack_exports__.iW;
var __webpack_exports__sleep = __webpack_exports__.yy;
var __webpack_exports__uploadBuildArtifact = __webpack_exports__.Yq;
var __webpack_exports__uploadReleaseAsset = __webpack_exports__.fC;
export { __webpack_exports__buildExtension as buildExtension, __webpack_exports__default as default, __webpack_exports__determineArchitecture as determineArchitecture, __webpack_exports__determineExtensionNameFromComposerJson as determineExtensionNameFromComposerJson, __webpack_exports__determineLibcFlavour as determineLibcFlavour, __webpack_exports__determineOperatingSystem as determineOperatingSystem, __webpack_exports__determinePhpBinary as determinePhpBinary, __webpack_exports__determinePhpDebugMode as determinePhpDebugMode, __webpack_exports__determinePhpVersionFromPhpConfig as determinePhpVersionFromPhpConfig, __webpack_exports__determineZendThreadSafeMode as determineZendThreadSafeMode, __webpack_exports__extensionDetails as extensionDetails, __webpack_exports__main as main, __webpack_exports__uploadBuildArtifact as uploadBuildArtifact, __webpack_exports__uploadReleaseAsset as uploadReleaseAsset };
export { __webpack_exports__buildExtension as buildExtension, __webpack_exports__createRelease as createRelease, __webpack_exports__default as default, __webpack_exports__deleteReleaseLock as deleteReleaseLock, __webpack_exports__determineArchitecture as determineArchitecture, __webpack_exports__determineExtensionNameFromComposerJson as determineExtensionNameFromComposerJson, __webpack_exports__determineLibcFlavour as determineLibcFlavour, __webpack_exports__determineOperatingSystem as determineOperatingSystem, __webpack_exports__determinePhpBinary as determinePhpBinary, __webpack_exports__determinePhpDebugMode as determinePhpDebugMode, __webpack_exports__determinePhpVersionFromPhpConfig as determinePhpVersionFromPhpConfig, __webpack_exports__determineZendThreadSafeMode as determineZendThreadSafeMode, __webpack_exports__extensionDetails as extensionDetails, __webpack_exports__findRelease as findRelease, __webpack_exports__getReleaseNotesFromTag as getReleaseNotesFromTag, __webpack_exports__main as main, __webpack_exports__sleep as sleep, __webpack_exports__uploadBuildArtifact as uploadBuildArtifact, __webpack_exports__uploadReleaseAsset as uploadReleaseAsset };

//# sourceMappingURL=index.js.map
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

Loading