├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── action.yml └── src └── main.py /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !Pipfile* 3 | !src 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .venv 3 | 4 | # Dev 5 | .env 6 | .vscode 7 | Test.md 8 | .github/workflows/dev.yaml 9 | *.secrets 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | 3 | WORKDIR /action 4 | 5 | COPY ./Pipfile* ./ 6 | RUN pip install pipenv && \ 7 | pipenv install --system --deploy && \ 8 | pipenv --clear 9 | 10 | COPY ./src . 11 | 12 | ENTRYPOINT [ "python" ] 13 | CMD [ "/action/main.py" ] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Niccolo Borgioli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "*" 8 | markdown = "*" 9 | py-gfm = "*" 10 | python-dotenv = "*" 11 | 12 | [dev-packages] 13 | autopep8 = "*" 14 | mypy = "*" 15 | 16 | [requires] 17 | python_version = "3.10" 18 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "d6b13a7d2799118601293cb0223043b384b6904d96f267f21ce4a596d59ac954" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", 22 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" 23 | ], 24 | "markers": "python_full_version >= '3.6.0'", 25 | "version": "==2022.12.7" 26 | }, 27 | "charset-normalizer": { 28 | "hashes": [ 29 | "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", 30 | "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" 31 | ], 32 | "markers": "python_full_version >= '3.6.0'", 33 | "version": "==2.1.1" 34 | }, 35 | "idna": { 36 | "hashes": [ 37 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 38 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 39 | ], 40 | "markers": "python_version >= '3.5'", 41 | "version": "==3.4" 42 | }, 43 | "markdown": { 44 | "hashes": [ 45 | "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186", 46 | "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff" 47 | ], 48 | "index": "pypi", 49 | "version": "==3.4.1" 50 | }, 51 | "py-gfm": { 52 | "hashes": [ 53 | "sha256:8768b31bbfda8e1d52e2f32b363c138eedf52b1a81c06036b60ece576b2a652f", 54 | "sha256:c49f43b584e15bdbe569141c92aefc00542289b6d88d95b38117e3359a35cdfe" 55 | ], 56 | "index": "pypi", 57 | "version": "==2.0.0" 58 | }, 59 | "python-dotenv": { 60 | "hashes": [ 61 | "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5", 62 | "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045" 63 | ], 64 | "index": "pypi", 65 | "version": "==0.21.0" 66 | }, 67 | "requests": { 68 | "hashes": [ 69 | "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", 70 | "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" 71 | ], 72 | "index": "pypi", 73 | "version": "==2.28.1" 74 | }, 75 | "urllib3": { 76 | "hashes": [ 77 | "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", 78 | "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" 79 | ], 80 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 81 | "version": "==1.26.13" 82 | } 83 | }, 84 | "develop": { 85 | "autopep8": { 86 | "hashes": [ 87 | "sha256:8b1659c7f003e693199f52caffdc06585bb0716900bbc6a7442fd931d658c077", 88 | "sha256:ad924b42c2e27a1ac58e432166cc4588f5b80747de02d0d35b1ecbd3e7d57207" 89 | ], 90 | "index": "pypi", 91 | "version": "==2.0.0" 92 | }, 93 | "mypy": { 94 | "hashes": [ 95 | "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d", 96 | "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6", 97 | "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf", 98 | "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f", 99 | "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813", 100 | "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33", 101 | "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad", 102 | "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05", 103 | "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297", 104 | "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06", 105 | "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd", 106 | "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243", 107 | "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305", 108 | "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476", 109 | "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711", 110 | "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70", 111 | "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5", 112 | "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461", 113 | "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab", 114 | "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c", 115 | "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d", 116 | "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135", 117 | "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93", 118 | "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648", 119 | "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a", 120 | "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb", 121 | "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3", 122 | "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372", 123 | "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb", 124 | "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef" 125 | ], 126 | "index": "pypi", 127 | "version": "==0.991" 128 | }, 129 | "mypy-extensions": { 130 | "hashes": [ 131 | "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", 132 | "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" 133 | ], 134 | "version": "==0.4.3" 135 | }, 136 | "pycodestyle": { 137 | "hashes": [ 138 | "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", 139 | "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" 140 | ], 141 | "markers": "python_version >= '3.6'", 142 | "version": "==2.10.0" 143 | }, 144 | "tomli": { 145 | "hashes": [ 146 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 147 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 148 | ], 149 | "markers": "python_version < '3.11'", 150 | "version": "==2.0.1" 151 | }, 152 | "typing-extensions": { 153 | "hashes": [ 154 | "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", 155 | "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" 156 | ], 157 | "markers": "python_version >= '3.7'", 158 | "version": "==4.4.0" 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Confluence Markdown Sync Action 2 | 3 | This Github Action serves the purpose of copying the contents of a Markdown `.md` file to a Confluence Cloud Page. 4 | 5 | ## Getting Started 6 | 7 | ```yml 8 | # .github/workflows/my-workflow.yml 9 | on: [push] 10 | 11 | jobs: 12 | dev: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - uses: cupcakearmy/confluence-markdown-sync@v1 18 | with: 19 | from: './README.md' 20 | to: '123456' # The confluence page id where to write the output 21 | cloud: 22 | user: 23 | token: 24 | ``` 25 | 26 | ## Authentication 27 | 28 | Uses basic auth for the rest api. 29 | 30 | - `cloud`: Can be either: 31 | - A subdomain (`acme` for Atlassian hosted instances (e.g. `https://acme.atlassian.net`)) 32 | - A full URL (e.g., `https://mycompany.com` for self-hosted instances) 33 | 34 | - `user`: The user that generated the access token 35 | 36 | - `token`: You can generate the token [here](https://id.atlassian.com/manage-profile/security/api-tokens). Link to [Docs](https://confluence.atlassian.com/cloud/api-tokens-938839638.html) 37 | 38 | - `to`: The page ID can be found by simply navigating to the page where you want the content to be posted to and look at the url. It will look something like this: 39 | - For Atlassian hosted: `https://.atlassian.net/wiki/spaces//pages//` 40 | - For self-hosted: `https://<your-url>/wiki/spaces/<space>/pages/<page-id>/<title>` 41 | 42 | ### Using secrets 43 | 44 | It's **higly reccomended** that you use secrets! 45 | 46 | To use them you need them to specify them before in your repo. [Docs](https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets) 47 | 48 | Then you can use them in any input field. 49 | 50 | ```yml 51 | # .github/workflows/my-workflow.yml 52 | # ... 53 | token: ${{ secrets.token }} 54 | ``` 55 | 56 | ## Known Limitations 57 | 58 | For now images will not be uploaded [see ticket](https://github.com/cupcakearmy/confluence-markdown-sync/issues/5), they would require extra steps. If anyone feedls brave enough, constributions are welcomed :) 59 | 60 | ## Development 61 | 62 | 1. Clone the repo 63 | 2. Install [act](https://github.com/nektos/act) 64 | 3. Create the same config in the repo folder as in the getting started section above. 65 | 4. Change `uses: cupcakearmy/confluence-markdown-sync` -> `uses: ./` 66 | 5. Create an example markdown file `Some.md` and set it in the config `from: './Some.md'` 67 | 6. Run locally `act -b` 68 | 69 | ### With secrets 70 | 71 | You can simply create a `.secrets` file and specify it to `act`. 72 | 73 | ``` 74 | TOKEN=abc123 75 | ``` 76 | 77 | ```yml 78 | # .github/workflows/dev.yml 79 | # ... 80 | token: ${{ secrets.token }} 81 | ``` 82 | 83 | ```bash 84 | act -b --secret-file .secrets 85 | ``` 86 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # action.yml 2 | name: confluence-markdown-sync 3 | description: Copy content of a markdown file to a confluence site 4 | branding: 5 | icon: upload-cloud 6 | color: blue 7 | inputs: 8 | from: 9 | description: Path to the markdown file. Relative to root of repository 10 | required: true 11 | to: 12 | description: The page ID in confluence 13 | required: true 14 | cloud: 15 | description: Atlassian Cloud ID 16 | required: true 17 | user: 18 | description: Username of the token user 19 | required: true 20 | token: 21 | description: Token for the user 22 | required: true 23 | runs: 24 | using: docker 25 | image: Dockerfile 26 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from os.path import join 3 | from typing import Dict 4 | 5 | import requests 6 | from dotenv import load_dotenv 7 | from markdown import markdown 8 | from mdx_gfm import GithubFlavoredMarkdownExtension 9 | 10 | load_dotenv() 11 | 12 | workspace = environ.get('GITHUB_WORKSPACE') 13 | if not workspace: 14 | print('No workspace is set') 15 | exit(1) 16 | 17 | envs: Dict[str, str] = {} 18 | for key in ['from', 'to', 'cloud', 'user', 'token']: 19 | value = environ.get(f'INPUT_{key.upper()}') 20 | if not value: 21 | print(f'Missing value for {key}') 22 | exit(1) 23 | envs[key] = value 24 | 25 | with open(join(workspace, envs['from'])) as f: 26 | md = f.read() 27 | 28 | base_url = envs['cloud'] 29 | if '://' in base_url: # It's a full URL 30 | # Remove trailing slash if present 31 | base_url = base_url.rstrip('/') 32 | url = f"{base_url}/wiki/rest/api/content/{envs['to']}" 33 | else: # It's a subdomain 34 | url = f"https://{base_url}.atlassian.net/wiki/rest/api/content/{envs['to']}" 35 | 36 | current = requests.get(url, auth=(envs['user'], envs['token'])).json() 37 | 38 | html = markdown(md, extensions=[GithubFlavoredMarkdownExtension()]) 39 | content = { 40 | 'id': current['id'], 41 | 'type': current['type'], 42 | 'title': current['title'], 43 | 'version': {'number': current['version']['number'] + 1}, 44 | 'body': { 45 | 'editor': { 46 | 'value': html, 47 | 'representation': 'editor' 48 | } 49 | } 50 | } 51 | 52 | updated = requests.put(url, json=content, auth=( 53 | envs['user'], envs['token'])).json() 54 | link = updated['_links']['base'] + updated['_links']['webui'] 55 | print(f'Uploaded content successfully to page {link}') 56 | --------------------------------------------------------------------------------