mirror of
https://github.com/nick-fields/retry.git
synced 2026-02-10 07:05:29 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe62d592e5 | ||
|
|
ba8a013d7c | ||
|
|
12ca35256f | ||
|
|
82da33c127 | ||
|
|
a8fec9e69d | ||
|
|
197f6abf5a | ||
|
|
fd45cc91d8 | ||
|
|
7082f1b92e | ||
|
|
d4bdaeed19 | ||
|
|
6513c5eede | ||
|
|
c95842ec20 | ||
|
|
9417ab4993 | ||
|
|
07cd61dba6 | ||
|
|
ce71cc2ab8 | ||
|
|
b3eed5aa93 | ||
|
|
d6b241c90e | ||
|
|
41b1e1aaef | ||
|
|
8d92921684 | ||
|
|
361088214a | ||
|
|
c97818ca39 | ||
|
|
dfb235ae84 | ||
|
|
3f757583fb | ||
|
|
7152eba30c | ||
|
|
14672906e6 | ||
|
|
1139f998ef | ||
|
|
1d41e5db1a | ||
|
|
1859f94181 | ||
|
|
943e742917 | ||
|
|
0711ba3d78 | ||
|
|
3e91a01664 | ||
|
|
48bc5d4b1c | ||
|
|
7d4a377045 | ||
|
|
b4fa57557d | ||
|
|
616fa81820 | ||
|
|
a25f198007 | ||
|
|
0f986c438b | ||
|
|
3dad7de805 | ||
|
|
14b6b46d04 | ||
|
|
f2eb0f4f8a | ||
|
|
2762157955 | ||
|
|
ce44dab6c9 | ||
|
|
40cf3886b8 | ||
|
|
02a3f09f15 | ||
|
|
6b1204d918 | ||
|
|
8629cc7c0b | ||
|
|
e88a9994b0 | ||
|
|
e4acf08f18 | ||
|
|
51e448da7c | ||
|
|
5f63400863 | ||
|
|
c0687a0dcd | ||
|
|
102f21a736 | ||
|
|
752366eac8 | ||
|
|
a3da592761 | ||
|
|
7c5cca7536 | ||
|
|
f227091f2e | ||
|
|
6183d5c3dd | ||
|
|
71062288b7 | ||
|
|
afe1ef9058 | ||
|
|
e53cf64f16 | ||
|
|
7f8f3d9f0f | ||
|
|
bf1736e338 | ||
|
|
f7cf641580 |
1
.config/.eslintignore
Normal file
1
.config/.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
.eslintrc.js
|
||||
7
.config/.eslintrc.js
Normal file
7
.config/.eslintrc.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
ignorePatterns: ['**/*.js', 'dist/'],
|
||||
};
|
||||
2
.config/.prettierignore
Normal file
2
.config/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
5
.config/.prettierrc.yml
Normal file
5
.config/.prettierrc.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
trailingComma: 'es5'
|
||||
tabWidth: 2
|
||||
semi: true
|
||||
singleQuote: true
|
||||
printWidth: 100
|
||||
13
.config/jest.config.js
Normal file
13
.config/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
clearMocks: true,
|
||||
moduleFileExtensions: ['js', 'ts'],
|
||||
rootDir: '..',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/src/**/*.test.ts'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
verbose: true,
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: ['src/**/*.{js,ts,jsx,tsx}'],
|
||||
};
|
||||
5
.github/ISSUE_TEMPLATE/bug-report.md
vendored
5
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@@ -3,8 +3,7 @@ name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: nick-invision
|
||||
|
||||
assignees: nick-fields
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -17,4 +16,4 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
Enable [debug logging](https://docs.github.com/en/free-pro-team@latest/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging) then attach the [raw logs](https://docs.github.com/en/free-pro-team@latest/actions/managing-workflow-runs/using-workflow-run-logs#downloading-logs) (specifically the raw output of this action).
|
||||
Enable [debug logging](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging#enabling-step-debug-logging) then attach the [raw logs](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/using-workflow-run-logs#downloading-logs) (specifically the raw output of this action).
|
||||
|
||||
15
.github/actions/setup/action.yml
vendored
Normal file
15
.github/actions/setup/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Setup Node, PNPM, cache, and install dependencies
|
||||
description: Sets up job to use the nodejs version in .nvmrc, pnpm, cache, and install dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ./.nvmrc
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install
|
||||
shell: bash
|
||||
12
.github/codecov.yml
vendored
Normal file
12
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# see https://docs.codecov.com/docs/codecovyml-reference
|
||||
codecov:
|
||||
require_ci_to_pass: false
|
||||
comment:
|
||||
layout: 'diff, flags'
|
||||
behavior: default
|
||||
require_changes: true
|
||||
coverage:
|
||||
# don't pass/fail PRs for coverage yet
|
||||
status:
|
||||
project: off
|
||||
patch: off
|
||||
10
.github/pull_request_template.md
vendored
Normal file
10
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
_Replace the bullet points below with your answers_
|
||||
|
||||
### Description
|
||||
|
||||
- What change is being made and why?
|
||||
|
||||
### Testing
|
||||
|
||||
- What tests were added?
|
||||
- These can be either ["integration tests"](./workflows/ci_cd.yml) or unit tests
|
||||
586
.github/workflows/ci_cd.yml
vendored
586
.github/workflows/ci_cd.yml
vendored
@@ -1,23 +1,104 @@
|
||||
name: CI/CD
|
||||
|
||||
# PRs come in via the GitHub UI that skip pre-commit hooks which bundle the code. So we have to check that the code is bundled properly here and potentially re-commit if changes are found
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
cancel-in-progress: ${{ github.ref_name == 'master' && false || true}}
|
||||
|
||||
on:
|
||||
# only on PRs into and merge to default branch
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
- master
|
||||
|
||||
env:
|
||||
BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
|
||||
PR_TRIGGERED: ${{ github.event_name == 'pull_request' && github.event.action != 'labeled' }}
|
||||
jobs:
|
||||
# runs on branch pushes only
|
||||
ci:
|
||||
name: Run Tests
|
||||
if: startsWith(github.ref, 'refs/heads')
|
||||
runs-on: ubuntu-18.04
|
||||
setup:
|
||||
# Skip running if labeled event but not rerun, otehrwise its a supported trigger event
|
||||
if: ${{ github.event.label.name == 'rerun' || github.event.action != 'labeled' }}
|
||||
name: Setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
node-version: 12
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
ref: ${{ env.BRANCH }}
|
||||
- name: Setup and cache for later jobs
|
||||
uses: ./.github/actions/setup
|
||||
# GHA requires bundled code to be commited so we bundle automatically via pre-commit hook. However, most contributions come in through the GH UI which skips hooks.
|
||||
# So bundle again here just in case, and if changes are found then commit them to ensure we're testing the correct code.
|
||||
# But GHA does not trigger on changes made via the default GH token, so we need to label the PR to re-run CI.
|
||||
# Alternatives could be using a PAT (rotation/security burden), GH App (overkill/maintenance), or something like pre-commit.ci (not convincing). Lets try this first though
|
||||
- name: Check for bundle changes after install
|
||||
# Only run if not a push event since we only trigger this on the default branch and we don't want to make any non-PR changes there
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
id: changes
|
||||
run: |
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "CHANGES_FOUND=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No changes detected."
|
||||
echo "CHANGES_FOUND=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Commit and push changes if found
|
||||
if: ${{ steps.changes.outputs.CHANGES_FOUND == 'true' }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "patch: regenerated bundle"
|
||||
git push
|
||||
- name: Label PR to re-trigger CI
|
||||
if: ${{ steps.changes.outputs.CHANGES_FOUND == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} --add-label "rerun"
|
||||
- name: Remove label after clean re-run
|
||||
# Only remove label if the PR was labeled with rerun and no changes were found. Changes found indicate a problem since they should have been previously committed
|
||||
if: ${{ steps.changes.outputs.CHANGES_FOUND == 'false' && github.event.label.name == 'rerun' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} --remove-label "rerun"
|
||||
|
||||
ci_unit:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run Unit Tests
|
||||
run: npm test
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
directory: ./coverage/
|
||||
verbose: true
|
||||
|
||||
ci_integration:
|
||||
name: Run Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: happy-path
|
||||
id: happy_path
|
||||
@@ -26,7 +107,7 @@ jobs:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
command: npm -v
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: true
|
||||
actual: ${{ steps.happy_path.outputs.total_attempts == '1' && steps.happy_path.outputs.exit_code == '0' }}
|
||||
@@ -37,6 +118,230 @@ jobs:
|
||||
command: node ./.github/scripts/log-examples.js
|
||||
timeout_minutes: 1
|
||||
|
||||
- name: sad-path (error)
|
||||
id: sad_path_error
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
command: node -e "process.exit(1)"
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.sad_path_error.outputs.total_attempts }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.sad_path_error.outcome }}
|
||||
|
||||
- name: retry_on (timeout) fails early if error encountered
|
||||
id: retry_on_timeout_fail
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 3
|
||||
retry_on: timeout
|
||||
command: node -e "process.exit(2)"
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 1
|
||||
actual: ${{ steps.retry_on_timeout_fail.outputs.total_attempts }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_timeout_fail.outcome }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_timeout_fail.outputs.exit_code }}
|
||||
|
||||
- name: retry_on (error)
|
||||
id: retry_on_error
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
command: node -e "process.exit(2)"
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_error.outputs.total_attempts }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_error.outcome }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_error.outputs.exit_code }}
|
||||
|
||||
- name: sad-path (wrong shell for OS)
|
||||
id: wrong_shell
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
shell: cmd
|
||||
command: 'dir'
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.wrong_shell.outputs.total_attempts }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.wrong_shell.outcome }}
|
||||
|
||||
ci_integration_envvar:
|
||||
name: Run Integration Env Var Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: env-vars-passed-through
|
||||
uses: ./
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=3072'
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
command: node -e 'console.log(process.env.NODE_OPTIONS)'
|
||||
|
||||
ci_integration_large_output:
|
||||
name: Run Integration Large Output Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test 100MiB of output can be processed
|
||||
id: large-output
|
||||
continue-on-error: true
|
||||
uses: ./
|
||||
with:
|
||||
max_attempts: 1
|
||||
timeout_minutes: 5
|
||||
command: 'make -C ./test-data/large-output bytes-102400'
|
||||
- name: Assert test had expected result
|
||||
uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.large-output.outcome }}
|
||||
- name: Assert exit code is expected
|
||||
uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.large-output.outputs.exit_code }}
|
||||
|
||||
ci_integration_retry_on_exit_code:
|
||||
name: Run Integration retry_on_exit_code Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: retry_on_exit_code (with expected error code)
|
||||
id: retry_on_exit_code_expected
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
retry_on_exit_code: 2
|
||||
max_attempts: 3
|
||||
command: node -e "process.exit(2)"
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_exit_code_expected.outcome }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 3
|
||||
actual: ${{ steps.retry_on_exit_code_expected.outputs.total_attempts }}
|
||||
|
||||
- name: retry_on_exit_code (with unexpected error code)
|
||||
id: retry_on_exit_code_unexpected
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
retry_on_exit_code: 2
|
||||
max_attempts: 3
|
||||
command: node -e "process.exit(1)"
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_exit_code_unexpected.outcome }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 1
|
||||
actual: ${{ steps.retry_on_exit_code_unexpected.outputs.total_attempts }}
|
||||
|
||||
ci_integration_continue_on_error:
|
||||
name: Run Integration continue_on_error Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: happy-path (continue_on_error)
|
||||
id: happy_path_continue_on_error
|
||||
uses: ./
|
||||
with:
|
||||
command: node -e "process.exit(0)"
|
||||
timeout_minutes: 1
|
||||
continue_on_error: true
|
||||
- name: sad-path (continue_on_error)
|
||||
id: sad_path_continue_on_error
|
||||
uses: ./
|
||||
with:
|
||||
command: node -e "process.exit(33)"
|
||||
timeout_minutes: 1
|
||||
continue_on_error: true
|
||||
- name: Verify continue_on_error returns correct exit code on success
|
||||
uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 0
|
||||
actual: ${{ steps.happy_path_continue_on_error.outputs.exit_code }}
|
||||
- name: Verify continue_on_error exits with correct outcome on success
|
||||
uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: success
|
||||
actual: ${{ steps.happy_path_continue_on_error.outcome }}
|
||||
- name: Verify continue_on_error returns correct exit code on error
|
||||
uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 33
|
||||
actual: ${{ steps.sad_path_continue_on_error.outputs.exit_code }}
|
||||
- name: Verify continue_on_error exits with successful outcome when an error occurs
|
||||
uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: success
|
||||
actual: ${{ steps.sad_path_continue_on_error.outcome }}
|
||||
|
||||
ci_integration_retry_wait_seconds:
|
||||
name: Run Integration Tests (retry_wait_seconds)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: sad-path (retry_wait_seconds)
|
||||
id: sad_path_wait_sec
|
||||
uses: ./
|
||||
@@ -46,20 +351,39 @@ jobs:
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 15
|
||||
command: npm install this-isnt-a-real-package-name-zzz
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 3
|
||||
actual: ${{ steps.sad_path_wait_sec.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.sad_path_wait_sec.outcome }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 'Final attempt failed'
|
||||
actual: ${{ steps.sad_path_wait_sec.outputs.exit_error }}
|
||||
comparison: contains
|
||||
|
||||
ci_integration_on_retry_cmd:
|
||||
name: Run Integration Tests (on_retry_command)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: new-command-on-retry
|
||||
id: new-command-on-retry
|
||||
uses: ./
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 3
|
||||
command: node -e "process.exit(1)"
|
||||
new_command_on_retry: node -e "console.log('this is the new command on retry')"
|
||||
|
||||
- name: on-retry-cmd
|
||||
id: on-retry-cmd
|
||||
uses: ./
|
||||
@@ -80,103 +404,17 @@ jobs:
|
||||
command: node -e "process.exit(1)"
|
||||
on_retry_command: node -e "throw new Error('This is an on-retry command error')"
|
||||
|
||||
- name: sad-path (error)
|
||||
id: sad_path_error
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
command: node -e "process.exit(1)"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.sad_path_error.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.sad_path_error.outcome }}
|
||||
# timeout tests take longer to run so run in parallel
|
||||
ci_integration_timeout_seconds:
|
||||
name: Run Integration Timeout Tests (seconds)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: happy-path (continue_on_error)
|
||||
id: happy_path_continue_on_error
|
||||
uses: ./
|
||||
with:
|
||||
command: node -e "process.exit(0)"
|
||||
timeout_minutes: 1
|
||||
continue_on_error: true
|
||||
- name: sad-path (continue_on_error)
|
||||
id: sad_path_continue_on_error
|
||||
uses: ./
|
||||
with:
|
||||
command: node -e "process.exit(33)"
|
||||
timeout_minutes: 1
|
||||
continue_on_error: true
|
||||
- name: Verify continue_on_error returns correct exit code on success
|
||||
uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 0
|
||||
actual: ${{ steps.happy_path_continue_on_error.outputs.exit_code }}
|
||||
- name: Verify continue_on_error exits with correct outcome on success
|
||||
uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: success
|
||||
actual: ${{ steps.happy_path_continue_on_error.outcome }}
|
||||
- name: Verify continue_on_error returns correct exit code on error
|
||||
uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 33
|
||||
actual: ${{ steps.sad_path_continue_on_error.outputs.exit_code }}
|
||||
- name: Verify continue_on_error exits with successful outcome when an error occurs
|
||||
uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: success
|
||||
actual: ${{ steps.sad_path_continue_on_error.outcome }}
|
||||
|
||||
- name: retry_on (timeout) fails early if error encountered
|
||||
id: retry_on_timeout_fail
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 3
|
||||
retry_on: timeout
|
||||
command: node -e "process.exit(2)"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 1
|
||||
actual: ${{ steps.retry_on_timeout_fail.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_timeout_fail.outcome }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_timeout_fail.outputs.exit_code }}
|
||||
|
||||
- name: retry_on (error)
|
||||
id: retry_on_error
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
command: node -e "process.exit(2)"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_error.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_error.outcome }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_error.outputs.exit_code }}
|
||||
|
||||
# timeout tests (takes longer to run so run last)
|
||||
- name: sad-path (timeout)
|
||||
id: sad_path_timeout
|
||||
uses: ./
|
||||
@@ -185,15 +423,25 @@ jobs:
|
||||
timeout_seconds: 15
|
||||
max_attempts: 2
|
||||
command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.sad_path_timeout.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.sad_path_timeout.outcome }}
|
||||
|
||||
ci_integration_timeout_retry_on_timeout:
|
||||
name: Run Integration Timeout Tests (retry_on timeout)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: retry_on (timeout)
|
||||
id: retry_on_timeout
|
||||
uses: ./
|
||||
@@ -203,15 +451,25 @@ jobs:
|
||||
max_attempts: 2
|
||||
retry_on: timeout
|
||||
command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.retry_on_timeout.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_timeout.outcome }}
|
||||
|
||||
ci_integration_timeout_retry_on_error:
|
||||
name: Run Integration Timeout Tests (retry_on error)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: retry_on (error) fails early if timeout encountered
|
||||
id: retry_on_error_fail
|
||||
uses: ./
|
||||
@@ -221,19 +479,29 @@ jobs:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 1
|
||||
actual: ${{ steps.retry_on_error_fail.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry_on_error_fail.outcome }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 1
|
||||
actual: ${{ steps.retry_on_error_fail.outputs.exit_code }}
|
||||
|
||||
ci_integration_timeout_minutes:
|
||||
name: Run Integration Timeout Tests (minutes)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: sad-path (timeout minutes)
|
||||
id: sad_path_timeout_minutes
|
||||
uses: ./
|
||||
@@ -242,46 +510,24 @@ jobs:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()"
|
||||
- uses: nick-invision/assert-action@v1
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.sad_path_timeout.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
actual: ${{ steps.sad_path_timeout_minutes.outputs.total_attempts }}
|
||||
- uses: nick-fields/assert-action@v2
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.sad_path_timeout.outcome }}
|
||||
|
||||
- name: sad-path (wrong shell for OS)
|
||||
id: wrong_shell
|
||||
uses: ./
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
shell: cmd
|
||||
command: 'dir'
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: 2
|
||||
actual: ${{ steps.wrong_shell.outputs.total_attempts }}
|
||||
- uses: nick-invision/assert-action@v1
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.wrong_shell.outcome }}
|
||||
actual: ${{ steps.sad_path_timeout_minutes.outcome }}
|
||||
|
||||
ci_windows:
|
||||
name: Run Windows Tests
|
||||
if: startsWith(github.ref, 'refs/heads')
|
||||
runs-on: windows-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Powershell test
|
||||
uses: ./
|
||||
with:
|
||||
@@ -321,27 +567,47 @@ jobs:
|
||||
echo "this is
|
||||
a test"
|
||||
|
||||
# runs on push to master only
|
||||
ci_all_tests_passed:
|
||||
name: All tests passed
|
||||
needs:
|
||||
[
|
||||
ci_unit,
|
||||
ci_integration,
|
||||
ci_integration_envvar,
|
||||
ci_integration_large_output,
|
||||
ci_integration_on_retry_cmd,
|
||||
ci_integration_retry_wait_seconds,
|
||||
ci_integration_continue_on_error,
|
||||
ci_integration_retry_on_exit_code,
|
||||
ci_integration_timeout_seconds,
|
||||
ci_integration_timeout_minutes,
|
||||
ci_integration_timeout_retry_on_timeout,
|
||||
ci_integration_timeout_retry_on_error,
|
||||
ci_windows,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "If this is hit, all tests successfully passed"
|
||||
|
||||
# runs on merge to default only
|
||||
cd:
|
||||
name: Publish Action
|
||||
needs: ci
|
||||
needs: [ci_all_tests_passed]
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Release
|
||||
id: semantic
|
||||
uses: cycjimmy/semantic-release-action@v2
|
||||
uses: cycjimmy/semantic-release-action@v4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Tag
|
||||
# only bump v# (e.g., v3) tag if semantic release action publishes any new version
|
||||
if: ${{ steps.semantic.outputs.new_release_major_version != '' }}
|
||||
run: git tag -f v${MAJOR_VERSION} && git push -f origin v${MAJOR_VERSION}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
5
.husky/commit-msg
Executable file
5
.husky/commit-msg
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# lint commit message
|
||||
npx --no -- commitlint --config ./.config/.commitlintrc.js --edit $1
|
||||
8
.husky/pre-commit
Executable file
8
.husky/pre-commit
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# run lint/styling on staged changes
|
||||
npx lint-staged
|
||||
|
||||
# regenerate dist
|
||||
npm run prepare && git add .
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
tabWidth: 2,
|
||||
printWidth: 100,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
};
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode"]
|
||||
}
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.requireConfig": true,
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.tabSize": 2
|
||||
"prettier.configPath": "./.config/.prettierrc.yml",
|
||||
"prettier.ignorePath": "./.config/.prettierignore",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
65
README.md
65
README.md
@@ -2,6 +2,10 @@
|
||||
|
||||
Retries an Action step on failure or timeout. This is currently intended to replace the `run` step for moody commands.
|
||||
|
||||
**NOTE:** Ownership of this project was transferred to my personal account `nick-fields` from my work account `nick-invision`. Details [here](#Ownership)
|
||||
|
||||
---
|
||||
|
||||
## Inputs
|
||||
|
||||
### `timeout_minutes`
|
||||
@@ -26,7 +30,7 @@ Retries an Action step on failure or timeout. This is currently intended to repl
|
||||
|
||||
### `shell`
|
||||
|
||||
**Optional** Shell to use to execute `command`. Defaults to `powershell` on Windows, `bash` otherwise. Supports bash, python, pwsh, sh, cmd, and powershell per [docs](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#using-a-specific-shell)
|
||||
**Optional** Shell to use to execute `command`. Defaults to `powershell` on Windows, `bash` otherwise. Supports bash, python, pwsh, sh, cmd, and powershell per [docs](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell)
|
||||
|
||||
### `polling_interval_seconds`
|
||||
|
||||
@@ -44,10 +48,18 @@ Retries an Action step on failure or timeout. This is currently intended to repl
|
||||
|
||||
**Optional** Command to run before a retry (such as a cleanup script). Any error thrown from retry command is caught and surfaced as a warning.
|
||||
|
||||
### `new_command_on_retry`
|
||||
|
||||
**Optional** Command to run if the first attempt fails. This command will be called on all subsequent attempts.
|
||||
|
||||
### `continue_on_error`
|
||||
|
||||
**Optional** Exit successfully even if an error occurs. Same as native continue-on-error behavior, but for use in composite actions. Defaults to `false`
|
||||
|
||||
### `retry_on_exit_code`
|
||||
|
||||
**Optional** Specific exit code to retry on. This will only retry for the given error code and fail immediately other error codes.
|
||||
|
||||
## Outputs
|
||||
|
||||
### `total_attempts`
|
||||
@@ -67,7 +79,7 @@ The final error returned by the command
|
||||
### Shell
|
||||
|
||||
```yaml
|
||||
uses: nick-invision/retry@v2
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
@@ -78,7 +90,7 @@ with:
|
||||
### Timeout in minutes
|
||||
|
||||
```yaml
|
||||
uses: nick-invision/retry@v2
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
@@ -88,7 +100,7 @@ with:
|
||||
### Timeout in seconds
|
||||
|
||||
```yaml
|
||||
uses: nick-invision/retry@v2
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
max_attempts: 3
|
||||
@@ -98,7 +110,7 @@ with:
|
||||
### Only retry after timeout
|
||||
|
||||
```yaml
|
||||
uses: nick-invision/retry@v2
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
max_attempts: 3
|
||||
@@ -109,7 +121,7 @@ with:
|
||||
### Only retry after error
|
||||
|
||||
```yaml
|
||||
uses: nick-invision/retry@v2
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
max_attempts: 3
|
||||
@@ -120,7 +132,7 @@ with:
|
||||
### Retry using continue_on_error input (in composite action) but allow failure and do something with output
|
||||
|
||||
```yaml
|
||||
- uses: nick-invision/retry@v2
|
||||
- uses: nick-fields/retry@v3
|
||||
id: retry
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
@@ -128,12 +140,12 @@ with:
|
||||
continue_on_error: true
|
||||
command: node -e 'process.exit(99);'
|
||||
- name: Assert that step succeeded (despite failing command)
|
||||
uses: nick-invision/assert-action@v1
|
||||
uses: nick-fields/assert-action@v1
|
||||
with:
|
||||
expected: success
|
||||
actual: ${{ steps.retry.outcome }}
|
||||
- name: Assert that action exited with expected exit code
|
||||
uses: nick-invision/assert-action@v1
|
||||
uses: nick-fields/assert-action@v1
|
||||
with:
|
||||
expected: 99
|
||||
actual: ${{ steps.retry.outputs.exit_code }}
|
||||
@@ -142,9 +154,9 @@ with:
|
||||
### Retry using continue-on-error built-in command (in workflow action) but allow failure and do something with output
|
||||
|
||||
```yaml
|
||||
- uses: nick-invision/retry@v2
|
||||
- uses: nick-fields/retry@v3
|
||||
id: retry
|
||||
# see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idcontinue-on-error
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontinue-on-error
|
||||
continue-on-error: true
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
@@ -152,17 +164,17 @@ with:
|
||||
retry_on: error
|
||||
command: node -e 'process.exit(99);'
|
||||
- name: Assert that action failed
|
||||
uses: nick-invision/assert-action@v1
|
||||
uses: nick-fields/assert-action@v1
|
||||
with:
|
||||
expected: failure
|
||||
actual: ${{ steps.retry.outcome }}
|
||||
- name: Assert that action exited with expected exit code
|
||||
uses: nick-invision/assert-action@v1
|
||||
uses: nick-fields/assert-action@v1
|
||||
with:
|
||||
expected: 99
|
||||
actual: ${{ steps.retry.outputs.exit_code }}
|
||||
- name: Assert that action made expected number of attempts
|
||||
uses: nick-invision/assert-action@v1
|
||||
uses: nick-fields/assert-action@v1
|
||||
with:
|
||||
expected: 3
|
||||
actual: ${{ steps.retry.outputs.total_attempts }}
|
||||
@@ -171,7 +183,7 @@ with:
|
||||
### Run script after failure but before retry
|
||||
|
||||
```yaml
|
||||
uses: nick-invision/retry@v2
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
max_attempts: 3
|
||||
@@ -179,11 +191,22 @@ with:
|
||||
on_retry_command: npm run cleanup-flaky-script-output
|
||||
```
|
||||
|
||||
### Run different command after first failure
|
||||
|
||||
```yaml
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_seconds: 15
|
||||
max_attempts: 3
|
||||
command: npx jest
|
||||
new_command_on_retry: npx jest --onlyFailures
|
||||
```
|
||||
|
||||
### Run multi-line, multi-command script
|
||||
|
||||
```yaml
|
||||
name: Multi-line multi-command Test
|
||||
uses: ./
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
@@ -196,7 +219,7 @@ with:
|
||||
|
||||
```yaml
|
||||
name: Multi-line single-command Test
|
||||
uses: ./
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 1
|
||||
max_attempts: 2
|
||||
@@ -209,3 +232,11 @@ with:
|
||||
## Requirements
|
||||
|
||||
NodeJS is required for this action to run. This runs without issue on all GitHub hosted runners but if you are running into issues with this on self hosted runners ensure NodeJS is installed.
|
||||
|
||||
---
|
||||
|
||||
## **Ownership**
|
||||
|
||||
As of 2022/02/15 ownership of this project has been transferred to my personal account `nick-fields` from my work account `nick-invision` due to me leaving InVision. I am the author and have been the primary maintainer since day one and will continue to maintain this as needed.
|
||||
|
||||
Existing workflow references to `nick-invision/retry@<whatever>` no longer work and must be updated to `nick-fields/retry@<whatever>`.
|
||||
|
||||
@@ -36,6 +36,12 @@ inputs:
|
||||
continue_on_error:
|
||||
description: Exits successfully even if an error occurs. Same as native continue-on-error behavior, but for use in composite actions. Default is false
|
||||
default: false
|
||||
new_command_on_retry:
|
||||
description: Command to run if the first attempt fails. This command will be called on all subsequent attempts.
|
||||
required: false
|
||||
retry_on_exit_code:
|
||||
description: Specific exit code to retry on. This will only retry for the given error code and fail immediately other error codes.
|
||||
required: false
|
||||
outputs:
|
||||
total_attempts:
|
||||
description: The final number of attempts made
|
||||
@@ -44,5 +50,5 @@ outputs:
|
||||
exit_error:
|
||||
description: The final error returned by the command
|
||||
runs:
|
||||
using: 'node12'
|
||||
using: 'node20'
|
||||
main: 'dist/index.js'
|
||||
|
||||
28272
dist/index.js
vendored
28272
dist/index.js
vendored
File diff suppressed because one or more lines are too long
7857
package-lock.json
generated
7857
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
65
package.json
65
package.json
@@ -3,43 +3,66 @@
|
||||
"version": "0.0.0-managed-by-semantic-release",
|
||||
"description": "Retries a GitHub Action step on failure or timeout.",
|
||||
"scripts": {
|
||||
"local": "npm run prepare && node -r dotenv/config ./dist/index.js",
|
||||
"prepare": "ncc build src/index.ts"
|
||||
"bundle": "ncc build src/index.ts",
|
||||
"lint:base": "eslint --config ./.config/.eslintrc.js ",
|
||||
"lint": "pnpm lint:base -- .",
|
||||
"local": "pnpm prepare && node -r dotenv/config ./dist/index.js",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "pnpm bundle && husky install",
|
||||
"style:base": "prettier --config ./.config/.prettierrc.yml --ignore-path ./.config/.prettierignore --write ",
|
||||
"style": "pnpm style:base -- .",
|
||||
"test": "jest -c ./.config/jest.config.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nick-invision/retry.git"
|
||||
"url": "git+https://github.com/nick-fields/retry.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"author": "Nick Fields",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/nick-invision/retry/issues"
|
||||
"url": "https://github.com/nick-fields/retry/issues"
|
||||
},
|
||||
"homepage": "https://github.com/nick-invision/retry#readme",
|
||||
"homepage": "https://github.com/nick-fields/retry#readme",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.5.0",
|
||||
"@actions/core": "^1.10.0",
|
||||
"milliseconds": "^1.0.3",
|
||||
"tree-kill": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "11.0.0",
|
||||
"@commitlint/config-conventional": "11.0.0",
|
||||
"@semantic-release/changelog": "5.0.1",
|
||||
"@semantic-release/git": "9.0.0",
|
||||
"@commitlint/cli": "^16.2.3",
|
||||
"@commitlint/config-conventional": "^16.2.1",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/jest": "^28.1.6",
|
||||
"@types/milliseconds": "0.0.30",
|
||||
"@types/node": "14.14.7",
|
||||
"@zeit/ncc": "^0.20.5",
|
||||
"@types/node": "^16.11.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||
"@typescript-eslint/parser": "^5.32.0",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"dotenv": "8.2.0",
|
||||
"husky": "4.3.0",
|
||||
"semantic-release": "17.2.3",
|
||||
"eslint": "^8.21.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"husky": "^8.0.1",
|
||||
"jest": "^28.1.3",
|
||||
"lint-staged": "^13.0.3",
|
||||
"prettier": "^2.7.1",
|
||||
"semantic-release": "^24.2.3",
|
||||
"ts-jest": "^28.0.7",
|
||||
"ts-node": "9.0.0",
|
||||
"typescript": "4.0.5"
|
||||
"typescript": "^4.7.4",
|
||||
"yaml-lint": "^1.7.0"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
|
||||
"pre-commit": "npm run prepare && git add ."
|
||||
}
|
||||
"lint-staged": {
|
||||
"**/*.ts": [
|
||||
"npm run style:base --",
|
||||
"npm run lint:base --"
|
||||
],
|
||||
"**/*.{md,yaml,yml}": [
|
||||
"npm run style:base --"
|
||||
],
|
||||
"**/*.{yaml,yml}": [
|
||||
"npx yamllint "
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
8334
pnpm-lock.yaml
generated
Normal file
8334
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
sample.env
12
sample.env
@@ -1,7 +1,11 @@
|
||||
# these are the bare minimum envvars required
|
||||
INPUT_TIMEOUT_MINUTES=1
|
||||
INPUT_MAX_ATTEMPTS=3
|
||||
INPUT_COMMAND="node -e 'process.exit(99)'"
|
||||
INPUT_RETRY_WAIT_SECONDS=10
|
||||
SHELL=pwsh
|
||||
INPUT_POLLING_INTERVAL_SECONDS=1
|
||||
INPUT_RETRY_ON=any
|
||||
INPUT_CONTINUE_ON_ERROR=false
|
||||
|
||||
# these are optional
|
||||
#INPUT_RETRY_WAIT_SECONDS=10
|
||||
#SHELL=pwsh
|
||||
#INPUT_POLLING_INTERVAL_SECONDS=1
|
||||
#INPUT_RETRY_ON=any
|
||||
|
||||
192
src/index.ts
192
src/index.ts
@@ -1,136 +1,87 @@
|
||||
import { getInput, error, warning, info, debug, setOutput } from '@actions/core';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { error, warning, info, debug, setOutput } from '@actions/core';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import ms from 'milliseconds';
|
||||
import kill from 'tree-kill';
|
||||
|
||||
import { wait } from './util';
|
||||
|
||||
// inputs
|
||||
const TIMEOUT_MINUTES = getInputNumber('timeout_minutes', false);
|
||||
const TIMEOUT_SECONDS = getInputNumber('timeout_seconds', false);
|
||||
const MAX_ATTEMPTS = getInputNumber('max_attempts', true) || 3;
|
||||
const COMMAND = getInput('command', { required: true });
|
||||
const RETRY_WAIT_SECONDS = getInputNumber('retry_wait_seconds', false) || 10;
|
||||
const SHELL = getInput('shell');
|
||||
const POLLING_INTERVAL_SECONDS = getInputNumber('polling_interval_seconds', false) || 1;
|
||||
const RETRY_ON = getInput('retry_on') || 'any';
|
||||
const WARNING_ON_RETRY = getInput('warning_on_retry').toLowerCase() === 'true';
|
||||
const ON_RETRY_COMMAND = getInput('on_retry_command');
|
||||
const CONTINUE_ON_ERROR = getInputBoolean('continue_on_error');
|
||||
import { getInputs, getTimeout, Inputs, validateInputs } from './inputs';
|
||||
import { retryWait, wait } from './util';
|
||||
|
||||
const OS = process.platform;
|
||||
const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts';
|
||||
const OUTPUT_EXIT_CODE_KEY = 'exit_code';
|
||||
const OUTPUT_EXIT_ERROR_KEY = 'exit_error';
|
||||
|
||||
var exit: number;
|
||||
var done: boolean;
|
||||
let exit: number;
|
||||
let done: boolean;
|
||||
|
||||
function getInputNumber(id: string, required: boolean): number | undefined {
|
||||
const input = getInput(id, { required });
|
||||
const num = Number.parseInt(input);
|
||||
info('test');
|
||||
|
||||
// empty is ok
|
||||
if (!input && !required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(num)) {
|
||||
throw `Input ${id} only accepts numbers. Received ${input}`;
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
function getInputBoolean(id: string): Boolean {
|
||||
const input = getInput(id);
|
||||
|
||||
if (!['true','false'].includes(input.toLowerCase())) {
|
||||
throw `Input ${id} only accepts boolean values. Received ${input}`;
|
||||
}
|
||||
return input.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
async function retryWait() {
|
||||
const waitStart = Date.now();
|
||||
await wait(ms.seconds(RETRY_WAIT_SECONDS));
|
||||
debug(`Waited ${Date.now() - waitStart}ms`);
|
||||
debug(`Configured wait: ${ms.seconds(RETRY_WAIT_SECONDS)}ms`);
|
||||
}
|
||||
|
||||
async function validateInputs() {
|
||||
if ((!TIMEOUT_MINUTES && !TIMEOUT_SECONDS) || (TIMEOUT_MINUTES && TIMEOUT_SECONDS)) {
|
||||
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeout(): number {
|
||||
if (TIMEOUT_MINUTES) {
|
||||
return ms.minutes(TIMEOUT_MINUTES);
|
||||
} else if (TIMEOUT_SECONDS) {
|
||||
return ms.seconds(TIMEOUT_SECONDS);
|
||||
}
|
||||
|
||||
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
|
||||
}
|
||||
|
||||
function getExecutable(): string {
|
||||
if (!SHELL) {
|
||||
function getExecutable(inputs: Inputs): string {
|
||||
if (!inputs.shell) {
|
||||
return OS === 'win32' ? 'powershell' : 'bash';
|
||||
}
|
||||
|
||||
let executable: string;
|
||||
switch (SHELL) {
|
||||
case "bash":
|
||||
case "python":
|
||||
case "pwsh": {
|
||||
executable = SHELL;
|
||||
const shellName = inputs.shell.split(' ')[0];
|
||||
|
||||
switch (shellName) {
|
||||
case 'bash':
|
||||
case 'python':
|
||||
case 'pwsh': {
|
||||
executable = inputs.shell;
|
||||
break;
|
||||
}
|
||||
case "sh": {
|
||||
case 'sh': {
|
||||
if (OS === 'win32') {
|
||||
throw new Error(`Shell ${SHELL} not allowed on OS ${OS}`);
|
||||
throw new Error(`Shell ${shellName} not allowed on OS ${OS}`);
|
||||
}
|
||||
executable = SHELL;
|
||||
executable = inputs.shell;
|
||||
break;
|
||||
}
|
||||
case "cmd":
|
||||
case "powershell": {
|
||||
case 'cmd':
|
||||
case 'powershell': {
|
||||
if (OS !== 'win32') {
|
||||
throw new Error(`Shell ${SHELL} not allowed on OS ${OS}`);
|
||||
throw new Error(`Shell ${shellName} not allowed on OS ${OS}`);
|
||||
}
|
||||
executable = SHELL + ".exe";
|
||||
executable = shellName + '.exe' + inputs.shell.replace(shellName, '');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Shell ${SHELL} not supported. See https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#using-a-specific-shell for supported shells`);
|
||||
throw new Error(
|
||||
`Shell ${shellName} not supported. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell for supported shells`
|
||||
);
|
||||
}
|
||||
}
|
||||
return executable
|
||||
return executable;
|
||||
}
|
||||
|
||||
async function runRetryCmd(): Promise<void> {
|
||||
async function runRetryCmd(inputs: Inputs): Promise<void> {
|
||||
// if no retry script, just continue
|
||||
if (!ON_RETRY_COMMAND) {
|
||||
if (!inputs.on_retry_command) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execSync(ON_RETRY_COMMAND, { stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
info(`WARNING: Retry command threw the error ${error.message}`)
|
||||
await execSync(inputs.on_retry_command, { stdio: 'inherit' });
|
||||
// eslint-disable-next-line
|
||||
} catch (error: any) {
|
||||
info(`WARNING: Retry command threw the error ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCmd() {
|
||||
const end_time = Date.now() + getTimeout();
|
||||
const executable = getExecutable();
|
||||
async function runCmd(attempt: number, inputs: Inputs) {
|
||||
const end_time = Date.now() + getTimeout(inputs);
|
||||
const executable = getExecutable(inputs);
|
||||
|
||||
exit = 0;
|
||||
done = false;
|
||||
let timeout = false;
|
||||
|
||||
debug(`Running command ${COMMAND} on ${OS} using shell ${executable}`)
|
||||
var child = exec(COMMAND, { 'shell': executable });
|
||||
debug(`Running command ${inputs.command} on ${OS} using shell ${executable}`);
|
||||
const child =
|
||||
attempt > 1 && inputs.new_command_on_retry
|
||||
? spawn(inputs.new_command_on_retry, { shell: executable })
|
||||
: spawn(inputs.command, { shell: executable });
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
process.stdout.write(data);
|
||||
@@ -142,64 +93,81 @@ async function runCmd() {
|
||||
child.on('exit', (code, signal) => {
|
||||
debug(`Code: ${code}`);
|
||||
debug(`Signal: ${signal}`);
|
||||
if (code && code > 0) {
|
||||
exit = code;
|
||||
}
|
||||
|
||||
// timeouts are killed manually
|
||||
if (signal === 'SIGTERM') {
|
||||
return;
|
||||
}
|
||||
|
||||
// On Windows signal is null.
|
||||
if (timeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && code > 0) {
|
||||
exit = code;
|
||||
}
|
||||
|
||||
done = true;
|
||||
});
|
||||
|
||||
do {
|
||||
await wait(ms.seconds(POLLING_INTERVAL_SECONDS));
|
||||
await wait(ms.seconds(inputs.polling_interval_seconds));
|
||||
} while (Date.now() < end_time && !done);
|
||||
|
||||
if (!done) {
|
||||
if (!done && child.pid) {
|
||||
timeout = true;
|
||||
kill(child.pid);
|
||||
await retryWait();
|
||||
throw new Error(`Timeout of ${getTimeout()}ms hit`);
|
||||
await retryWait(ms.seconds(inputs.retry_wait_seconds));
|
||||
throw new Error(`Timeout of ${getTimeout(inputs)}ms hit`);
|
||||
} else if (exit > 0) {
|
||||
await retryWait();
|
||||
await retryWait(ms.seconds(inputs.retry_wait_seconds));
|
||||
throw new Error(`Child_process exited with error code ${exit}`);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction() {
|
||||
await validateInputs();
|
||||
async function runAction(inputs: Inputs) {
|
||||
await validateInputs(inputs);
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
for (let attempt = 1; attempt <= inputs.max_attempts; attempt++) {
|
||||
info(`::group::Attempt ${attempt}`);
|
||||
try {
|
||||
// just keep overwriting attempts output
|
||||
setOutput(OUTPUT_TOTAL_ATTEMPTS_KEY, attempt);
|
||||
await runCmd();
|
||||
await runCmd(attempt, inputs);
|
||||
info(`Command completed after ${attempt} attempt(s).`);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === MAX_ATTEMPTS) {
|
||||
// eslint-disable-next-line
|
||||
} catch (error: any) {
|
||||
if (attempt === inputs.max_attempts) {
|
||||
throw new Error(`Final attempt failed. ${error.message}`);
|
||||
} else if (!done && RETRY_ON === 'error') {
|
||||
} else if (!done && inputs.retry_on === 'error') {
|
||||
// error: timeout
|
||||
throw error;
|
||||
} else if (exit > 0 && RETRY_ON === 'timeout') {
|
||||
} else if (inputs.retry_on_exit_code && inputs.retry_on_exit_code !== exit) {
|
||||
throw error;
|
||||
} else if (exit > 0 && inputs.retry_on === 'timeout') {
|
||||
// error: error
|
||||
throw error;
|
||||
} else {
|
||||
await runRetryCmd();
|
||||
if (WARNING_ON_RETRY) {
|
||||
await runRetryCmd(inputs);
|
||||
if (inputs.warning_on_retry) {
|
||||
warning(`Attempt ${attempt} failed. Reason: ${error.message}`);
|
||||
} else {
|
||||
info(`Attempt ${attempt} failed. Reason: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
info(`::endgroup::`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runAction()
|
||||
const inputs = getInputs();
|
||||
|
||||
runAction(inputs)
|
||||
.then(() => {
|
||||
setOutput(OUTPUT_EXIT_CODE_KEY, 0);
|
||||
process.exit(0); // success
|
||||
@@ -208,7 +176,7 @@ runAction()
|
||||
// exact error code if available, otherwise just 1
|
||||
const exitCode = exit > 0 ? exit : 1;
|
||||
|
||||
if (CONTINUE_ON_ERROR) {
|
||||
if (inputs.continue_on_error) {
|
||||
warning(err.message);
|
||||
} else {
|
||||
error(err.message);
|
||||
@@ -220,5 +188,5 @@ runAction()
|
||||
|
||||
// if continue_on_error, exit with exact error code else exit gracefully
|
||||
// mimics native continue-on-error that is not supported in composite actions
|
||||
process.exit(CONTINUE_ON_ERROR ? 0 : exitCode);
|
||||
process.exit(inputs.continue_on_error ? 0 : exitCode);
|
||||
});
|
||||
|
||||
94
src/inputs.ts
Normal file
94
src/inputs.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getInput } from '@actions/core';
|
||||
import ms from 'milliseconds';
|
||||
|
||||
export interface Inputs {
|
||||
timeout_minutes: number | undefined;
|
||||
timeout_seconds: number | undefined;
|
||||
max_attempts: number;
|
||||
command: string;
|
||||
retry_wait_seconds: number;
|
||||
shell: string | undefined;
|
||||
polling_interval_seconds: number;
|
||||
retry_on: string | undefined;
|
||||
warning_on_retry: boolean;
|
||||
on_retry_command: string | undefined;
|
||||
continue_on_error: boolean;
|
||||
new_command_on_retry: string | undefined;
|
||||
retry_on_exit_code: number | undefined;
|
||||
}
|
||||
|
||||
export function getInputNumber(id: string, required: boolean): number | undefined {
|
||||
const input = getInput(id, { required });
|
||||
const num = Number.parseInt(input);
|
||||
|
||||
// empty is ok
|
||||
if (!input && !required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(num)) {
|
||||
throw `Input ${id} only accepts numbers. Received ${input}`;
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
export function getInputBoolean(id: string): boolean {
|
||||
const input = getInput(id);
|
||||
|
||||
if (!['true', 'false'].includes(input.toLowerCase())) {
|
||||
throw `Input ${id} only accepts boolean values. Received ${input}`;
|
||||
}
|
||||
return input.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
export async function validateInputs(inputs: Inputs) {
|
||||
if (
|
||||
(!inputs.timeout_minutes && !inputs.timeout_seconds) ||
|
||||
(inputs.timeout_minutes && inputs.timeout_seconds)
|
||||
) {
|
||||
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeout(inputs: Inputs): number {
|
||||
if (inputs.timeout_minutes) {
|
||||
return ms.minutes(inputs.timeout_minutes);
|
||||
} else if (inputs.timeout_seconds) {
|
||||
return ms.seconds(inputs.timeout_seconds);
|
||||
}
|
||||
|
||||
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
|
||||
}
|
||||
|
||||
export function getInputs(): Inputs {
|
||||
const timeout_minutes = getInputNumber('timeout_minutes', false);
|
||||
const timeout_seconds = getInputNumber('timeout_seconds', false);
|
||||
const max_attempts = getInputNumber('max_attempts', true) || 3;
|
||||
const command = getInput('command', { required: true });
|
||||
const retry_wait_seconds = getInputNumber('retry_wait_seconds', false) || 10;
|
||||
const shell = getInput('shell');
|
||||
const polling_interval_seconds = getInputNumber('polling_interval_seconds', false) || 1;
|
||||
const retry_on = getInput('retry_on') || 'any';
|
||||
const warning_on_retry = getInput('warning_on_retry').toLowerCase() === 'true';
|
||||
const on_retry_command = getInput('on_retry_command');
|
||||
const continue_on_error = getInputBoolean('continue_on_error');
|
||||
const new_command_on_retry = getInput('new_command_on_retry');
|
||||
const retry_on_exit_code = getInputNumber('retry_on_exit_code', false);
|
||||
|
||||
return {
|
||||
timeout_minutes,
|
||||
timeout_seconds,
|
||||
max_attempts,
|
||||
command,
|
||||
retry_wait_seconds,
|
||||
shell,
|
||||
polling_interval_seconds,
|
||||
retry_on,
|
||||
warning_on_retry,
|
||||
on_retry_command,
|
||||
continue_on_error,
|
||||
new_command_on_retry,
|
||||
retry_on_exit_code,
|
||||
};
|
||||
}
|
||||
22
src/util.test.ts
Normal file
22
src/util.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'jest';
|
||||
import { getHeapStatistics } from 'v8';
|
||||
|
||||
import { wait } from './util';
|
||||
|
||||
// otherwise, TypeError: Cannot assign to read only property 'performance' of object '[object global]'
|
||||
Object.defineProperty(global, 'performance', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// mocks the setTimeout function, see https://jestjs.io/docs/timer-mocks
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(global, 'setTimeout');
|
||||
|
||||
describe('util', () => {
|
||||
test('wait', async () => {
|
||||
const waitTime = 1000;
|
||||
wait(waitTime);
|
||||
expect(setTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), waitTime);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,12 @@
|
||||
import { debug } from '@actions/core';
|
||||
|
||||
export async function wait(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function retryWait(retryWaitSeconds: number) {
|
||||
const waitStart = Date.now();
|
||||
await wait(retryWaitSeconds);
|
||||
debug(`Waited ${Date.now() - waitStart}ms`);
|
||||
debug(`Configured wait: ${retryWaitSeconds}ms`);
|
||||
}
|
||||
|
||||
13
test-data/large-output/Makefile
Normal file
13
test-data/large-output/Makefile
Normal file
@@ -0,0 +1,13 @@
|
||||
SHELL = bash
|
||||
|
||||
# this tests fix for the following issues
|
||||
# https://github.com/nick-fields/retry/issues/76
|
||||
# https://github.com/nick-fields/retry/issues/84
|
||||
|
||||
bytes-%:
|
||||
for i in {1..$*}; do cat kibibyte.txt; done; exit 2
|
||||
.PHONY: bytes-%
|
||||
|
||||
lines-%:
|
||||
for i in {1..$*}; do echo a; done; exit 2
|
||||
.PHONY: lines-%
|
||||
13
test-data/large-output/kibibyte.txt
Normal file
13
test-data/large-output/kibibyte.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
1: 0000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
2: 0081 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
3: 0162 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
4: 243 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
5: 324 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
6: 405 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
7: 486 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
8: 567 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
9: 648 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
a: 729 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
b: 810 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
c: 891 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
d: 972 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
Reference in New Issue
Block a user